mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-25 13:10:53 +02:00
v0.9.6: InfoNet hashchain, Wormhole gate encryption, mesh reputation, 16 community contributors
Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery, killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption keys and chain state during updates. New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers, CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets, desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing). Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami, @chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const, @Elhard1, @ttulttul
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.next
|
||||
node_modules
|
||||
public
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": true
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
+8
-8
@@ -19,14 +19,14 @@ The frontend needs to reach the backend (default port `8000`). Resolution order:
|
||||
|
||||
### Common scenarios
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### Setting the variable
|
||||
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTs from 'eslint-config-next/typescript';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/ban-ts-comment': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
'prefer-const': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.cjs', 'vitest.config.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
'coverage/**',
|
||||
'eslint-report.json',
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
+63
-3
@@ -1,13 +1,73 @@
|
||||
import type { NextConfig } from "next";
|
||||
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 skipTypecheck = process.env.NEXT_SKIP_TYPECHECK === '1';
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
isDev
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
|
||||
: "script-src 'self' 'unsafe-inline' blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
isDev
|
||||
? "connect-src 'self' ws: wss: http://127.0.0.1:8000 http://127.0.0.1:8787 https:"
|
||||
: "connect-src 'self' ws: wss: https:",
|
||||
"font-src 'self' data:",
|
||||
"object-src 'none'",
|
||||
"worker-src 'self' blob:",
|
||||
"child-src 'self' blob:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join('; '),
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'no-referrer',
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
},
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'],
|
||||
output: "standalone",
|
||||
transpilePackages: ['react-map-gl', 'maplibre-gl'],
|
||||
output: 'standalone',
|
||||
devIndicators: false,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: 'upload.wikimedia.org' },
|
||||
{ protocol: 'https', hostname: 'via.placeholder.com' },
|
||||
{ protocol: 'https', hostname: 'services.sentinel-hub.com' },
|
||||
{ protocol: 'https', hostname: 'data.sentinel-hub.com' },
|
||||
{ protocol: 'https', hostname: 'sentinel-hub.com' },
|
||||
{ protocol: 'https', hostname: 'dataspace.copernicus.eu' },
|
||||
],
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: skipTypecheck,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+21
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.5",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"framer-motion": "^12.34.3",
|
||||
@@ -17,7 +17,8 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"satellite.js": "^6.0.2"
|
||||
"satellite.js": "^6.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -31,6 +32,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^28.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.0"
|
||||
@@ -7350,6 +7352,22 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
@@ -9656,7 +9674,6 @@
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
+11
-5
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
||||
"dev": "node scripts/dev-all.cjs",
|
||||
"dev:frontend": "next dev",
|
||||
"dev:backend": "node ../start-backend.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"bundle:report": "node scripts/report-bundle-size.js",
|
||||
"test": "npm run test:ci",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "set NODE_OPTIONS=--require ./scripts/vite-no-net-use.cjs && vitest run --coverage --pool=threads",
|
||||
"test:ci": "set NODE_OPTIONS=--require ./scripts/vite-no-net-use.cjs && vitest run --pool=threads"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
@@ -23,7 +27,8 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"satellite.js": "^6.0.2"
|
||||
"satellite.js": "^6.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -37,6 +42,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^28.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const { spawn } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
const frontendDir = path.resolve(__dirname, "..");
|
||||
const backendLauncher = path.resolve(frontendDir, "..", "start-backend.js");
|
||||
const nextBin = require.resolve("next/dist/bin/next");
|
||||
|
||||
/** @type {import("child_process").ChildProcess[]} */
|
||||
const children = [];
|
||||
|
||||
function start(label, file, args, cwd) {
|
||||
const child = spawn(file, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`[${label}] failed to start:`, error);
|
||||
shutdown(1);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal || (code ?? 0) !== 0) {
|
||||
console.error(`[${label}] exited with ${signal ?? code}`);
|
||||
shutdown(typeof code === "number" ? code : 1);
|
||||
return;
|
||||
}
|
||||
shutdown(0);
|
||||
});
|
||||
|
||||
children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
function shutdown(exitCode) {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
for (const child of children) {
|
||||
if (!child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
start(
|
||||
"frontend",
|
||||
process.execPath,
|
||||
[nextBin, "dev", "--hostname", "127.0.0.1", "--port", "3000"],
|
||||
frontendDir,
|
||||
);
|
||||
start("backend", process.execPath, [backendLauncher], frontendDir);
|
||||
@@ -0,0 +1,33 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const nextDir = path.join(root, '.next');
|
||||
|
||||
function dirSize(p) {
|
||||
let total = 0;
|
||||
if (!fs.existsSync(p)) return 0;
|
||||
const stats = fs.statSync(p);
|
||||
if (stats.isFile()) return stats.size;
|
||||
for (const entry of fs.readdirSync(p)) {
|
||||
total += dirSize(path.join(p, entry));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
const total = dirSize(nextDir);
|
||||
const staticSize = dirSize(path.join(nextDir, 'static'));
|
||||
const serverSize = dirSize(path.join(nextDir, 'server'));
|
||||
|
||||
const toKb = (b) => Math.round(b / 1024);
|
||||
|
||||
console.log('Bundle size report');
|
||||
console.log(`.next total: ${toKb(total)} KB`);
|
||||
console.log(`.next/static: ${toKb(staticSize)} KB`);
|
||||
console.log(`.next/server: ${toKb(serverSize)} KB`);
|
||||
|
||||
const limitKb = process.env.BUNDLE_SIZE_LIMIT_KB ? Number(process.env.BUNDLE_SIZE_LIMIT_KB) : null;
|
||||
if (limitKb && toKb(total) > limitKb) {
|
||||
console.error(`Bundle size exceeds limit: ${toKb(total)} KB > ${limitKb} KB`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const childProcess = require('child_process');
|
||||
|
||||
const originalExec = childProcess.exec;
|
||||
|
||||
childProcess.exec = function exec(command, options, callback) {
|
||||
const cmd = typeof command === 'string' ? command.trim().toLowerCase() : '';
|
||||
if (cmd === 'net use') {
|
||||
const cb = typeof options === 'function' ? options : callback;
|
||||
if (typeof cb === 'function') {
|
||||
process.nextTick(() => cb(null, '', ''));
|
||||
}
|
||||
return {
|
||||
pid: 0,
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
on() {},
|
||||
kill() {},
|
||||
};
|
||||
}
|
||||
return originalExec.apply(this, arguments);
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { GET as proxyGet } from '@/app/api/[...path]/route';
|
||||
import {
|
||||
DELETE as deleteAdminSession,
|
||||
GET as getAdminSession,
|
||||
POST as postAdminSession,
|
||||
} from '@/app/api/admin/session/route';
|
||||
|
||||
function extractSessionCookie(setCookie: string): string {
|
||||
return setCookie.split(';')[0] || '';
|
||||
}
|
||||
|
||||
describe('admin/session boundary hardening', () => {
|
||||
const originalAdminKey = process.env.ADMIN_KEY;
|
||||
const originalBackendUrl = process.env.BACKEND_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ADMIN_KEY = 'top-secret';
|
||||
process.env.BACKEND_URL = 'http://127.0.0.1:8000';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ADMIN_KEY = originalAdminKey;
|
||||
process.env.BACKEND_URL = originalBackendUrl;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('rejects invalid admin keys before minting a session', async () => {
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'wrong-key' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const res = await postAdminSession(req);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.detail).toBe('Invalid admin key');
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a verified admin key and reports the minted session as present', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const res = await postAdminSession(req);
|
||||
const cookie = extractSessionCookie(res.headers.get('set-cookie') || '');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(cookie).toContain('sb_admin_session=');
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const getReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const getRes = await getAdminSession(getReq);
|
||||
const getBody = await getRes.json();
|
||||
|
||||
expect(getBody.ok).toBe(true);
|
||||
expect(getBody.hasSession).toBe(true);
|
||||
expect(getRes.headers.get('cache-control')).toContain('no-store');
|
||||
|
||||
const deleteReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'DELETE',
|
||||
headers: { cookie },
|
||||
});
|
||||
const deleteRes = await deleteAdminSession(deleteReq);
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(deleteRes.headers.get('cache-control')).toContain('no-store');
|
||||
});
|
||||
|
||||
it('invalidates the previous admin session token when a new one is minted', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const firstReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const firstRes = await postAdminSession(firstReq);
|
||||
const firstCookie = extractSessionCookie(firstRes.headers.get('set-cookie') || '');
|
||||
|
||||
const secondReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: firstCookie,
|
||||
},
|
||||
});
|
||||
const secondRes = await postAdminSession(secondReq);
|
||||
const secondCookie = extractSessionCookie(secondRes.headers.get('set-cookie') || '');
|
||||
|
||||
expect(secondCookie).toContain('sb_admin_session=');
|
||||
expect(secondCookie).not.toBe(firstCookie);
|
||||
|
||||
const oldSessionCheck = await getAdminSession(
|
||||
new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'GET',
|
||||
headers: { cookie: firstCookie },
|
||||
}),
|
||||
);
|
||||
const oldBody = await oldSessionCheck.json();
|
||||
expect(oldBody.hasSession).toBe(false);
|
||||
|
||||
const newSessionCheck = await getAdminSession(
|
||||
new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'GET',
|
||||
headers: { cookie: secondCookie },
|
||||
}),
|
||||
);
|
||||
const newBody = await newSessionCheck.json();
|
||||
expect(newBody.hasSession).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects session minting when frontend admin key is set but backend has no configured admin key', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: 'Forbidden — admin key not configured' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const res = await postAdminSession(req);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.detail).toBe('Forbidden — admin key not configured');
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not forward raw x-admin-key headers through the sensitive proxy path', async () => {
|
||||
process.env.ADMIN_KEY = '';
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/settings/api-keys', {
|
||||
method: 'GET',
|
||||
headers: { 'x-admin-key': 'browser-supplied-key' },
|
||||
});
|
||||
|
||||
const res = await proxyGet(req, { params: Promise.resolve({ path: ['settings', 'api-keys'] }) });
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
|
||||
const forwarded = fetchMock.mock.calls[0]?.[1];
|
||||
const forwardedHeaders = new Headers((forwarded as RequestInit | undefined)?.headers);
|
||||
expect(forwardedHeaders.get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards the minted admin session to sensitive proxy paths and preserves upstream errors', async () => {
|
||||
const verifyMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', verifyMock);
|
||||
|
||||
const sessionReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const sessionRes = await postAdminSession(sessionReq);
|
||||
const cookie = extractSessionCookie(sessionRes.headers.get('set-cookie') || '');
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: 'Forbidden upstream' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/wormhole/identity', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
|
||||
const res = await proxyGet(req, { params: Promise.resolve({ path: ['wormhole', 'identity'] }) });
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.detail).toBe('Forbidden upstream');
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
|
||||
const forwarded = fetchMock.mock.calls[0]?.[1];
|
||||
const forwardedHeaders = new Headers((forwarded as RequestInit | undefined)?.headers);
|
||||
expect(forwardedHeaders.get('X-Admin-Key')).toBe('top-secret');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const primeAdminSession = vi.fn();
|
||||
const localControlFetch = vi.fn();
|
||||
const hasLocalControlBridge = vi.fn();
|
||||
const canInvokeLocalControl = vi.fn();
|
||||
|
||||
vi.mock('@/lib/adminSession', () => ({
|
||||
primeAdminSession,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
localControlFetch,
|
||||
hasLocalControlBridge,
|
||||
canInvokeLocalControl,
|
||||
}));
|
||||
|
||||
describe('controlPlane native boundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
primeAdminSession.mockReset();
|
||||
localControlFetch.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
canInvokeLocalControl.mockReset();
|
||||
localControlFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips browser admin-session priming when a native bridge can invoke the request', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
canInvokeLocalControl.mockReturnValue(true);
|
||||
|
||||
const mod = await import('@/lib/controlPlane');
|
||||
await mod.controlPlaneFetch('/api/wormhole/gate/message/compose', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ gate_id: 'infonet', plaintext: 'hello' }),
|
||||
});
|
||||
|
||||
expect(primeAdminSession).not.toHaveBeenCalled();
|
||||
expect(localControlFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still primes browser admin-session when no native invoke path exists', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
canInvokeLocalControl.mockReturnValue(false);
|
||||
|
||||
const mod = await import('@/lib/controlPlane');
|
||||
await mod.controlPlaneFetch('/api/wormhole/identity');
|
||||
|
||||
expect(primeAdminSession).toHaveBeenCalledTimes(1);
|
||||
expect(localControlFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getDesktopNativeControlAuditReport,
|
||||
installDesktopControlBridge,
|
||||
} from '@/lib/desktopBridge';
|
||||
|
||||
describe('desktopBridge native audit access', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns the runtime audit report when available', () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
installDesktopControlBridge({
|
||||
invokeLocalControl: vi.fn(),
|
||||
getNativeControlAuditReport: vi.fn(() => ({
|
||||
totalEvents: 2,
|
||||
totalRecorded: 2,
|
||||
recent: [],
|
||||
byOutcome: { allowed: 2 },
|
||||
})),
|
||||
});
|
||||
|
||||
expect(getDesktopNativeControlAuditReport(5)).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 2,
|
||||
totalRecorded: 2,
|
||||
byOutcome: { allowed: 2 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when no runtime audit report is exposed', () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
installDesktopControlBridge({
|
||||
invokeLocalControl: vi.fn(),
|
||||
});
|
||||
|
||||
expect(getDesktopNativeControlAuditReport(5)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
describeNativeControlError,
|
||||
extractGateTargetRef,
|
||||
} from '../../lib/desktopControlContract';
|
||||
|
||||
describe('extractGateTargetRef', () => {
|
||||
it('extracts gate_id from gate key rotation payload', () => {
|
||||
expect(
|
||||
extractGateTargetRef('wormhole.gate.key.rotate', { gate_id: 'infonet', reason: 'test' }),
|
||||
).toBe('infonet');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate message compose payload', () => {
|
||||
expect(
|
||||
extractGateTargetRef('wormhole.gate.message.compose', { gate_id: 'ops', plaintext: 'hi' }),
|
||||
).toBe('ops');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate proof payload', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.proof', { gate_id: 'alpha' })).toBe('alpha');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate message post payload', () => {
|
||||
expect(
|
||||
extractGateTargetRef('wormhole.gate.message.post', { gate_id: 'ops', plaintext: 'hi' }),
|
||||
).toBe('ops');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate persona list payload', () => {
|
||||
expect(
|
||||
extractGateTargetRef('wormhole.gate.personas.get', { gate_id: 'alpha' }),
|
||||
).toBe('alpha');
|
||||
});
|
||||
|
||||
it('returns undefined for non-gate commands', () => {
|
||||
expect(extractGateTargetRef('wormhole.status', undefined)).toBeUndefined();
|
||||
expect(extractGateTargetRef('settings.news.get', undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when payload has no gate_id', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.key.rotate', { reason: 'test' })).toBeUndefined();
|
||||
expect(extractGateTargetRef('wormhole.gate.key.rotate', null)).toBeUndefined();
|
||||
expect(extractGateTargetRef('wormhole.gate.key.rotate', 'not-an-object')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when gate_id is empty string', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.key.get', { gate_id: '' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeNativeControlError', () => {
|
||||
it('describes profile mismatch errors', () => {
|
||||
const err = new Error('native_control_profile_mismatch:settings_only:wormhole_gate_key');
|
||||
const msg = describeNativeControlError(err);
|
||||
expect(msg).toContain('Denied');
|
||||
expect(msg).toContain('session profile');
|
||||
});
|
||||
|
||||
it('describes capability denied errors', () => {
|
||||
const err = new Error('native_control_capability_denied:wormhole_gate_key');
|
||||
const msg = describeNativeControlError(err);
|
||||
expect(msg).toContain('Denied');
|
||||
expect(msg).toContain('capability');
|
||||
});
|
||||
|
||||
it('describes capability mismatch errors', () => {
|
||||
const err = new Error('native_control_capability_mismatch:wormhole_gate_content:wormhole_gate_key');
|
||||
const msg = describeNativeControlError(err);
|
||||
expect(msg).toContain('Denied');
|
||||
expect(msg).toContain('capability');
|
||||
});
|
||||
|
||||
it('describes shim enforcement inactivity errors', () => {
|
||||
const err = new Error('desktop_runtime_shim_enforcement_inactive');
|
||||
const msg = describeNativeControlError(err);
|
||||
expect(msg).toContain('Denied');
|
||||
expect(msg).toContain('native runtime');
|
||||
});
|
||||
|
||||
it('returns null for unrelated errors', () => {
|
||||
expect(describeNativeControlError(new Error('network_error'))).toBeNull();
|
||||
expect(describeNativeControlError('some string')).toBeNull();
|
||||
expect(describeNativeControlError(null)).toBeNull();
|
||||
expect(describeNativeControlError(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles plain string errors', () => {
|
||||
expect(
|
||||
describeNativeControlError('native_control_profile_mismatch:foo'),
|
||||
).toContain('Denied');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
commandToHttpRequest,
|
||||
httpRequestToInvokeRequest,
|
||||
} from '@/lib/desktopControlRouting';
|
||||
|
||||
describe('desktopControlRouting', () => {
|
||||
it('maps invoke commands to HTTP requests', () => {
|
||||
expect(commandToHttpRequest('wormhole.connect')).toEqual({
|
||||
path: '/api/wormhole/connect',
|
||||
method: 'POST',
|
||||
});
|
||||
expect(commandToHttpRequest('wormhole.gate.key.get', { gate_id: 'infonet' })).toEqual({
|
||||
path: '/api/wormhole/gate/infonet/key',
|
||||
method: 'GET',
|
||||
});
|
||||
expect(commandToHttpRequest('settings.news.reset')).toEqual({
|
||||
path: '/api/settings/news-feeds/reset',
|
||||
method: 'POST',
|
||||
});
|
||||
expect(commandToHttpRequest('wormhole.gate.proof', { gate_id: 'infonet' })).toEqual({
|
||||
path: '/api/wormhole/gate/proof',
|
||||
method: 'POST',
|
||||
payload: { gate_id: 'infonet' },
|
||||
});
|
||||
expect(
|
||||
commandToHttpRequest('wormhole.gate.message.post', {
|
||||
gate_id: 'ops',
|
||||
plaintext: 'hello',
|
||||
}),
|
||||
).toEqual({
|
||||
path: '/api/wormhole/gate/message/post',
|
||||
method: 'POST',
|
||||
payload: { gate_id: 'ops', plaintext: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps HTTP settings writes back to invoke requests', () => {
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/settings/privacy-profile',
|
||||
'PUT',
|
||||
JSON.stringify({ profile: 'high' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'settings.privacy.set',
|
||||
payload: { profile: 'high' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/key/rotate',
|
||||
'POST',
|
||||
JSON.stringify({ gate_id: 'infonet', reason: 'operator_reset' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/proof',
|
||||
'POST',
|
||||
JSON.stringify({ gate_id: 'infonet' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.proof',
|
||||
payload: { gate_id: 'infonet' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/messages/decrypt',
|
||||
'POST',
|
||||
JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 3,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n',
|
||||
sender_ref: 'ref',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.messages.decrypt',
|
||||
payload: {
|
||||
messages: [
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 3,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n',
|
||||
sender_ref: 'ref',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unsupported paths', () => {
|
||||
expect(httpRequestToInvokeRequest('/api/mesh/status', 'GET')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createHttpBackedDesktopRuntime } from '@/lib/desktopRuntimeShim';
|
||||
|
||||
describe('desktopRuntimeShim enforcement guard', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const warnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('refuses strictly enforced commands in the HTTP-backed shim', async () => {
|
||||
const runtime = createHttpBackedDesktopRuntime();
|
||||
|
||||
await expect(
|
||||
runtime.invokeLocalControl?.(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('desktop_runtime_shim_enforcement_inactive');
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'[desktop-shim] strict native session-profile enforcement is unavailable in the HTTP-backed shim',
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
}),
|
||||
);
|
||||
expect(runtime.getNativeControlAuditReport?.(5)).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 1,
|
||||
totalRecorded: 1,
|
||||
byOutcome: expect.objectContaining({ shim_refused: 1 }),
|
||||
lastDenied: expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
targetRef: 'infonet',
|
||||
outcome: 'shim_refused',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('localControlTransport capability metadata', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('attaches capability intent metadata when invoking the native bridge', async () => {
|
||||
const invoke = vi.fn(async () => ({ ok: true }));
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
__SHADOWBROKER_LOCAL_CONTROL__: {
|
||||
invoke,
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mod = await import('@/lib/localControlTransport');
|
||||
await mod.localControlFetch('/api/wormhole/gate/key/rotate', {
|
||||
method: 'POST',
|
||||
capabilityIntent: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
body: JSON.stringify({ gate_id: 'infonet', reason: 'operator_reset' }),
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
meta: {
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to plain fetch when the HTTP-backed shim refuses strict enforcement', async () => {
|
||||
const invoke = vi.fn(async () => {
|
||||
throw new Error('desktop_runtime_shim_enforcement_inactive');
|
||||
});
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true, gate_id: 'infonet' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
__SHADOWBROKER_LOCAL_CONTROL__: {
|
||||
invoke,
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mod = await import('@/lib/localControlTransport');
|
||||
const res = await mod.localControlFetch('/api/wormhole/gate/proof', {
|
||||
method: 'POST',
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gate_id: 'infonet' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(invoke).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/wormhole/gate/proof',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ gate_id: 'infonet' }),
|
||||
}),
|
||||
);
|
||||
expect(data).toEqual({ ok: true, gate_id: 'infonet' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createNativeControlRouter } from '../../../../desktop-shell/src/nativeControlRouter';
|
||||
|
||||
describe('nativeControlRouter capability scaffolding', () => {
|
||||
it('rejects mismatched capability intent', async () => {
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{ capability: 'wormhole_gate_content' },
|
||||
),
|
||||
).rejects.toThrow('native_control_capability_mismatch');
|
||||
});
|
||||
|
||||
it('rejects commands outside the allowed native capability set', async () => {
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
allowedCapabilities: ['wormhole_gate_content'],
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{ capability: 'wormhole_gate_key' },
|
||||
),
|
||||
).rejects.toThrow('native_control_capability_denied');
|
||||
});
|
||||
|
||||
it('audits session-profile mismatch without denying by default', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
auditControlUse,
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
const result = await router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{ capability: 'wormhole_gate_key', sessionProfileHint: 'gate_operator' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(auditControlUse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
expectedCapability: 'wormhole_gate_key',
|
||||
targetRef: 'infonet',
|
||||
sessionProfile: 'settings_only',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
profileAllows: false,
|
||||
enforced: false,
|
||||
outcome: 'profile_warn',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes targetRef in audit events for gate commands', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
auditControlUse,
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await router.invoke(
|
||||
'wormhole.gate.message.compose',
|
||||
{ gate_id: 'ops-room', plaintext: 'hello' },
|
||||
{ capability: 'wormhole_gate_content' },
|
||||
);
|
||||
|
||||
expect(auditControlUse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.message.compose',
|
||||
targetRef: 'ops-room',
|
||||
outcome: 'allowed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits targetRef for non-gate commands', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
auditControlUse,
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await router.invoke('wormhole.status', undefined);
|
||||
|
||||
const event = auditControlUse.mock.calls[0][0];
|
||||
expect(event.command).toBe('wormhole.status');
|
||||
expect(event.targetRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it('can enforce session-profile mismatch when explicitly enabled', async () => {
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
enforceSessionProfile: true,
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{ capability: 'wormhole_gate_key', sessionProfileHint: 'gate_operator' },
|
||||
),
|
||||
).rejects.toThrow('native_control_profile_mismatch');
|
||||
});
|
||||
|
||||
it('can enforce a hinted session profile for a narrow gate-key command', async () => {
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('native_control_profile_mismatch');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createRuntimeBridge } from '../../../../desktop-shell/src/runtimeBridge';
|
||||
|
||||
describe('runtimeBridge session profile routing', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses the invocation session profile hint when the runtime context is unscoped', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const runtime = createRuntimeBridge({
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
auditControlUse,
|
||||
});
|
||||
|
||||
await runtime.invokeLocalControl(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(auditControlUse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
targetRef: 'infonet',
|
||||
sessionProfile: 'gate_operator',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
profileAllows: true,
|
||||
outcome: 'allowed',
|
||||
}),
|
||||
);
|
||||
|
||||
const report = runtime.getNativeControlAuditReport?.(5);
|
||||
expect(report).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 1,
|
||||
totalRecorded: 1,
|
||||
byOutcome: expect.objectContaining({ allowed: 1 }),
|
||||
}),
|
||||
);
|
||||
expect(report?.recent[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
targetRef: 'infonet',
|
||||
sessionProfile: 'gate_operator',
|
||||
outcome: 'allowed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an explicitly scoped runtime session profile over the invocation hint', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const runtime = createRuntimeBridge({
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
auditControlUse,
|
||||
});
|
||||
|
||||
await runtime.invokeLocalControl(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
},
|
||||
);
|
||||
|
||||
expect(auditControlUse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
sessionProfile: 'settings_only',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
profileAllows: false,
|
||||
outcome: 'profile_warn',
|
||||
}),
|
||||
);
|
||||
|
||||
const report = runtime.getNativeControlAuditReport?.(5);
|
||||
expect(report).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 1,
|
||||
totalRecorded: 1,
|
||||
byOutcome: expect.objectContaining({ profile_warn: 1 }),
|
||||
lastProfileMismatch: expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
sessionProfile: 'settings_only',
|
||||
outcome: 'profile_warn',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('denies a strictly hinted gate-key command when the runtime is pinned to another profile', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const runtime = createRuntimeBridge({
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.invokeLocalControl(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('native_control_profile_mismatch');
|
||||
|
||||
const report = runtime.getNativeControlAuditReport?.(5);
|
||||
expect(report).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 1,
|
||||
totalRecorded: 1,
|
||||
byOutcome: expect.objectContaining({ profile_denied: 1 }),
|
||||
lastDenied: expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
outcome: 'profile_denied',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildEarthquakesGeoJSON, buildJammingGeoJSON, buildCctvGeoJSON, buildKiwisdrGeoJSON,
|
||||
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON,
|
||||
buildGdeltGeoJSON, buildLiveuaGeoJSON, buildFrontlineGeoJSON, buildMilitaryBasesGeoJSON
|
||||
buildEarthquakesGeoJSON,
|
||||
buildJammingGeoJSON,
|
||||
buildCctvGeoJSON,
|
||||
buildKiwisdrGeoJSON,
|
||||
buildFirmsGeoJSON,
|
||||
buildInternetOutagesGeoJSON,
|
||||
buildDataCentersGeoJSON,
|
||||
buildGdeltGeoJSON,
|
||||
buildLiveuaGeoJSON,
|
||||
buildFrontlineGeoJSON,
|
||||
buildScannerGeoJSON,
|
||||
buildMilitaryBasesGeoJSON,
|
||||
buildTrainsGeoJSON,
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR, MilitaryBase } from '@/types/dashboard';
|
||||
import type {
|
||||
Earthquake,
|
||||
GPSJammingZone,
|
||||
FireHotspot,
|
||||
InternetOutage,
|
||||
DataCenter,
|
||||
GDELTIncident,
|
||||
LiveUAmapIncident,
|
||||
CCTVCamera,
|
||||
KiwiSDR,
|
||||
Scanner,
|
||||
MilitaryBase,
|
||||
Train,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
// ─── Military Bases ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,289 +57,500 @@ describe('buildMilitaryBasesGeoJSON', () => {
|
||||
// ─── Earthquakes ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildEarthquakesGeoJSON', () => {
|
||||
it('returns null for empty/undefined input', () => {
|
||||
expect(buildEarthquakesGeoJSON(undefined)).toBeNull();
|
||||
expect(buildEarthquakesGeoJSON([])).toBeNull();
|
||||
});
|
||||
it('returns null for empty/undefined input', () => {
|
||||
expect(buildEarthquakesGeoJSON(undefined)).toBeNull();
|
||||
expect(buildEarthquakesGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds valid FeatureCollection from earthquake data', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: 35.0, lng: 139.0, place: 'Japan' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -120.0, place: 'California', title: 'Test Title' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('FeatureCollection');
|
||||
expect(result!.features).toHaveLength(2);
|
||||
it('builds valid FeatureCollection from earthquake data', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: 35.0, lng: 139.0, place: 'Japan' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -120.0, place: 'California', title: 'Test Title' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('FeatureCollection');
|
||||
expect(result!.features).toHaveLength(2);
|
||||
|
||||
const f0 = result!.features[0];
|
||||
expect(f0.geometry).toEqual({ type: 'Point', coordinates: [139.0, 35.0] });
|
||||
expect(f0.properties?.type).toBe('earthquake');
|
||||
expect(f0.properties?.name).toContain('M5.2');
|
||||
expect(f0.properties?.name).toContain('Japan');
|
||||
});
|
||||
const f0 = result!.features[0];
|
||||
expect(f0.geometry).toEqual({ type: 'Point', coordinates: [139.0, 35.0] });
|
||||
expect(f0.properties?.type).toBe('earthquake');
|
||||
expect(f0.properties?.name).toContain('M5.2');
|
||||
expect(f0.properties?.name).toContain('Japan');
|
||||
});
|
||||
|
||||
it('filters out entries with null lat/lng', () => {
|
||||
const earthquakes = [
|
||||
{ id: 'eq1', mag: 5.0, lat: null as any, lng: 10.0, place: 'X' },
|
||||
{ id: 'eq2', mag: 3.0, lat: 20.0, lng: 30.0, place: 'Y' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters out entries with null lat/lng', () => {
|
||||
const earthquakes = [
|
||||
{ id: 'eq1', mag: 5.0, lat: null as any, lng: 10.0, place: 'X' },
|
||||
{ id: 'eq2', mag: 3.0, lat: 20.0, lng: 30.0, place: 'Y' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes title when present', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 4.0, lat: 10.0, lng: 20.0, place: 'Test', title: 'Big One' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features[0].properties?.title).toBe('Big One');
|
||||
});
|
||||
it('includes title when present', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 4.0, lat: 10.0, lng: 20.0, place: 'Test', title: 'Big One' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features[0].properties?.title).toBe('Big One');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GPS Jamming ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildJammingGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildJammingGeoJSON(undefined)).toBeNull();
|
||||
expect(buildJammingGeoJSON([])).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildJammingGeoJSON(undefined)).toBeNull();
|
||||
expect(buildJammingGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds polygon features with correct opacity mapping', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
{ lat: 45, lng: 35, severity: 'medium', ratio: 0.5, degraded: 50, total: 100 },
|
||||
{ lat: 40, lng: 25, severity: 'low', ratio: 0.2, degraded: 20, total: 100 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
expect(result!.features).toHaveLength(3);
|
||||
expect(result!.features[0].properties?.opacity).toBe(0.45);
|
||||
expect(result!.features[1].properties?.opacity).toBe(0.3);
|
||||
expect(result!.features[2].properties?.opacity).toBe(0.18);
|
||||
});
|
||||
it('builds polygon features with correct opacity mapping', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
{ lat: 45, lng: 35, severity: 'medium', ratio: 0.5, degraded: 50, total: 100 },
|
||||
{ lat: 40, lng: 25, severity: 'low', ratio: 0.2, degraded: 20, total: 100 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
expect(result!.features).toHaveLength(3);
|
||||
expect(result!.features[0].properties?.opacity).toBe(0.45);
|
||||
expect(result!.features[1].properties?.opacity).toBe(0.3);
|
||||
expect(result!.features[2].properties?.opacity).toBe(0.18);
|
||||
});
|
||||
|
||||
it('builds correct 1°×1° polygon geometry', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
const geom = result!.features[0].geometry;
|
||||
expect(geom.type).toBe('Polygon');
|
||||
if (geom.type === 'Polygon') {
|
||||
const ring = geom.coordinates[0];
|
||||
expect(ring).toHaveLength(5); // Closed ring
|
||||
expect(ring[0]).toEqual([29.5, 49.5]);
|
||||
expect(ring[2]).toEqual([30.5, 50.5]);
|
||||
}
|
||||
});
|
||||
it('builds correct 1°×1° polygon geometry', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
const geom = result!.features[0].geometry;
|
||||
expect(geom.type).toBe('Polygon');
|
||||
if (geom.type === 'Polygon') {
|
||||
const ring = geom.coordinates[0];
|
||||
expect(ring).toHaveLength(5); // Closed ring
|
||||
expect(ring[0]).toEqual([29.5, 49.5]);
|
||||
expect(ring[2]).toEqual([30.5, 50.5]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CCTV ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildCctvGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildCctvGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildCctvGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features from camera data', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0, direction_facing: 'North', source_agency: 'DOT' },
|
||||
];
|
||||
const result = buildCctvGeoJSON(cameras);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('cctv');
|
||||
expect(result!.features[0].properties?.name).toBe('North');
|
||||
});
|
||||
it('builds features from camera data', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0, direction_facing: 'North', source_agency: 'DOT' },
|
||||
];
|
||||
const result = buildCctvGeoJSON(cameras);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('cctv');
|
||||
expect(result!.features[0].properties?.name).toBe('North');
|
||||
});
|
||||
|
||||
it('respects inView filter', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0 },
|
||||
{ id: 'cam2', lat: 10.0, lon: 20.0 },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildCctvGeoJSON(cameras, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('respects inView filter', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0 },
|
||||
{ id: 'cam2', lat: 10.0, lon: 20.0 },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildCctvGeoJSON(cameras, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── KiwiSDR ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildKiwisdrGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildKiwisdrGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildKiwisdrGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with SDR properties', () => {
|
||||
const receivers: KiwiSDR[] = [
|
||||
{ lat: 52.0, lon: 13.0, name: 'Berlin SDR', url: 'http://test.com', users: 3, users_max: 8, bands: 'HF', antenna: 'Long Wire', location: 'Berlin' },
|
||||
];
|
||||
const result = buildKiwisdrGeoJSON(receivers);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.name).toBe('Berlin SDR');
|
||||
expect(result!.features[0].properties?.users).toBe(3);
|
||||
});
|
||||
it('builds features with SDR properties', () => {
|
||||
const receivers: KiwiSDR[] = [
|
||||
{
|
||||
lat: 52.0,
|
||||
lon: 13.0,
|
||||
name: 'Berlin SDR',
|
||||
url: 'http://test.com',
|
||||
users: 3,
|
||||
users_max: 8,
|
||||
bands: 'HF',
|
||||
antenna: 'Long Wire',
|
||||
location: 'Berlin',
|
||||
},
|
||||
];
|
||||
const result = buildKiwisdrGeoJSON(receivers);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.name).toBe('Berlin SDR');
|
||||
expect(result!.features[0].properties?.users).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIRMS Fires ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFirmsGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildFirmsGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildFirmsGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies fires by FRP thresholds', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 150, brightness: 400, confidence: 'high', daynight: 'D', acq_date: '2024-01-01', acq_time: '1200' },
|
||||
{ lat: 11, lng: 21, frp: 50, brightness: 350, confidence: 'medium', daynight: 'N', acq_date: '2024-01-01', acq_time: '0100' },
|
||||
{ lat: 12, lng: 22, frp: 10, brightness: 300, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1400' },
|
||||
{ lat: 13, lng: 23, frp: 2, brightness: 250, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1500' },
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features).toHaveLength(4);
|
||||
expect(result!.features[0].properties?.iconId).toBe('fire-darkred');
|
||||
expect(result!.features[1].properties?.iconId).toBe('fire-red');
|
||||
expect(result!.features[2].properties?.iconId).toBe('fire-orange');
|
||||
expect(result!.features[3].properties?.iconId).toBe('fire-yellow');
|
||||
});
|
||||
it('classifies fires by FRP thresholds', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{
|
||||
lat: 10,
|
||||
lng: 20,
|
||||
frp: 150,
|
||||
brightness: 400,
|
||||
confidence: 'high',
|
||||
daynight: 'D',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '1200',
|
||||
},
|
||||
{
|
||||
lat: 11,
|
||||
lng: 21,
|
||||
frp: 50,
|
||||
brightness: 350,
|
||||
confidence: 'medium',
|
||||
daynight: 'N',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '0100',
|
||||
},
|
||||
{
|
||||
lat: 12,
|
||||
lng: 22,
|
||||
frp: 10,
|
||||
brightness: 300,
|
||||
confidence: 'low',
|
||||
daynight: 'D',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '1400',
|
||||
},
|
||||
{
|
||||
lat: 13,
|
||||
lng: 23,
|
||||
frp: 2,
|
||||
brightness: 250,
|
||||
confidence: 'low',
|
||||
daynight: 'D',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '1500',
|
||||
},
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features).toHaveLength(4);
|
||||
expect(result!.features[0].properties?.iconId).toBe('fire-darkred');
|
||||
expect(result!.features[1].properties?.iconId).toBe('fire-red');
|
||||
expect(result!.features[2].properties?.iconId).toBe('fire-orange');
|
||||
expect(result!.features[3].properties?.iconId).toBe('fire-yellow');
|
||||
});
|
||||
|
||||
it('formats daynight correctly', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 5, brightness: 300, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1200' },
|
||||
{ lat: 11, lng: 21, frp: 5, brightness: 300, confidence: 'low', daynight: 'N', acq_date: '2024-01-01', acq_time: '0100' },
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features[0].properties?.daynight).toBe('Day');
|
||||
expect(result!.features[1].properties?.daynight).toBe('Night');
|
||||
});
|
||||
it('formats daynight correctly', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{
|
||||
lat: 10,
|
||||
lng: 20,
|
||||
frp: 5,
|
||||
brightness: 300,
|
||||
confidence: 'low',
|
||||
daynight: 'D',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '1200',
|
||||
},
|
||||
{
|
||||
lat: 11,
|
||||
lng: 21,
|
||||
frp: 5,
|
||||
brightness: 300,
|
||||
confidence: 'low',
|
||||
daynight: 'N',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '0100',
|
||||
},
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features[0].properties?.daynight).toBe('Day');
|
||||
expect(result!.features[1].properties?.daynight).toBe('Night');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Internet Outages ───────────────────────────────────────────────────────
|
||||
|
||||
describe('buildInternetOutagesGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildInternetOutagesGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildInternetOutagesGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with detail string', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{ region_code: 'TX', region_name: 'Texas', country_code: 'US', country_name: 'United States', lat: 31.0, lng: -100.0, severity: 45, level: 'region', datasource: 'bgp' },
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.detail).toContain('Texas');
|
||||
expect(result!.features[0].properties?.detail).toContain('45% drop');
|
||||
});
|
||||
it('builds features with detail string', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{
|
||||
region_code: 'TX',
|
||||
region_name: 'Texas',
|
||||
country_code: 'US',
|
||||
country_name: 'United States',
|
||||
lat: 31.0,
|
||||
lng: -100.0,
|
||||
severity: 45,
|
||||
level: 'region',
|
||||
datasource: 'bgp',
|
||||
},
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.detail).toContain('Texas');
|
||||
expect(result!.features[0].properties?.detail).toContain('45% drop');
|
||||
});
|
||||
|
||||
it('filters out entries with null coordinates', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{ region_code: 'TX', region_name: 'Texas', country_code: 'US', country_name: 'United States', lat: null as any, lng: null as any, severity: 20, level: 'region', datasource: 'bgp' },
|
||||
{ region_code: 'CA', region_name: 'California', country_code: 'US', country_name: 'United States', lat: 37.0, lng: -122.0, severity: 30, level: 'region', datasource: 'bgp' },
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters out entries with null coordinates', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{
|
||||
region_code: 'TX',
|
||||
region_name: 'Texas',
|
||||
country_code: 'US',
|
||||
country_name: 'United States',
|
||||
lat: null as any,
|
||||
lng: null as any,
|
||||
severity: 20,
|
||||
level: 'region',
|
||||
datasource: 'bgp',
|
||||
},
|
||||
{
|
||||
region_code: 'CA',
|
||||
region_name: 'California',
|
||||
country_code: 'US',
|
||||
country_name: 'United States',
|
||||
lat: 37.0,
|
||||
lng: -122.0,
|
||||
severity: 30,
|
||||
level: 'region',
|
||||
datasource: 'bgp',
|
||||
},
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Data Centers ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildDataCentersGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildDataCentersGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildDataCentersGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with datacenter properties', () => {
|
||||
const dcs: DataCenter[] = [
|
||||
{ lat: 40.0, lng: -74.0, name: 'NYC-DC1', company: 'Equinix', street: '123 Main', city: 'New York', country: 'US', zip: '10001' },
|
||||
];
|
||||
const result = buildDataCentersGeoJSON(dcs);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.id).toBe('dc-0');
|
||||
expect(result!.features[0].properties?.company).toBe('Equinix');
|
||||
});
|
||||
it('builds features with datacenter properties', () => {
|
||||
const dcs: DataCenter[] = [
|
||||
{
|
||||
lat: 40.0,
|
||||
lng: -74.0,
|
||||
name: 'NYC-DC1',
|
||||
company: 'Equinix',
|
||||
street: '123 Main',
|
||||
city: 'New York',
|
||||
country: 'US',
|
||||
zip: '10001',
|
||||
},
|
||||
];
|
||||
const result = buildDataCentersGeoJSON(dcs);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.id).toBe('dc-0');
|
||||
expect(result!.features[0].properties?.company).toBe('Equinix');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GDELT ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildGdeltGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildGdeltGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildGdeltGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features from GDELT incidents', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'Protest', count: 5, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('gdelt');
|
||||
expect(result!.features[0].properties?.title).toBe('Protest');
|
||||
});
|
||||
it('builds features from GDELT incidents', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [30, 50] },
|
||||
properties: { name: 'Protest', count: 5, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('gdelt');
|
||||
expect(result!.features[0].properties?.title).toBe('Protest');
|
||||
});
|
||||
|
||||
it('filters by inView when provided', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'A', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [100, 10] }, properties: { name: 'B', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildGdeltGeoJSON(gdelt, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters by inView when provided', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [30, 50] },
|
||||
properties: { name: 'A', count: 1, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [100, 10] },
|
||||
properties: { name: 'B', count: 1, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildGdeltGeoJSON(gdelt, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters out entries without geometry', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'Good', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
{ type: 'Feature', geometry: null as any, properties: { name: 'Bad', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters out entries without geometry', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [30, 50] },
|
||||
properties: { name: 'Good', count: 1, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: null as any,
|
||||
properties: { name: 'Bad', count: 1, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTrainsGeoJSON', () => {
|
||||
it('builds all trains when no inView filter is provided', () => {
|
||||
const trains: Train[] = [
|
||||
{
|
||||
id: 'amtrak-1',
|
||||
name: 'Empire Builder',
|
||||
number: '7',
|
||||
source: 'amtrak',
|
||||
source_label: 'Amtraker',
|
||||
operator: 'Amtrak',
|
||||
country: 'US',
|
||||
speed_kmh: 88,
|
||||
heading: 90,
|
||||
status: 'active',
|
||||
route: 'SEA-CHI',
|
||||
lat: 47.6,
|
||||
lng: -122.3,
|
||||
},
|
||||
{
|
||||
id: 'fin-1',
|
||||
name: 'Pendolino',
|
||||
number: 'S 94',
|
||||
source: 'digitraffic',
|
||||
source_label: 'Digitraffic',
|
||||
operator: 'VR',
|
||||
country: 'FI',
|
||||
speed_kmh: 120,
|
||||
heading: 180,
|
||||
status: 'active',
|
||||
route: 'HEL-TKU',
|
||||
lat: 60.17,
|
||||
lng: 24.94,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildTrainsGeoJSON(trains);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.features).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LiveUAMap ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildLiveuaGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildLiveuaGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildLiveuaGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies violent incidents with red icon', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Missile strike in Kharkiv', date: '2024-01-01' },
|
||||
{ id: '2', lat: 49.0, lng: 36.0, title: 'Humanitarian aid delivery', date: '2024-01-01' },
|
||||
];
|
||||
const result = buildLiveuaGeoJSON(incidents);
|
||||
expect(result!.features).toHaveLength(2);
|
||||
expect(result!.features[0].properties?.iconId).toBe('icon-liveua-red');
|
||||
expect(result!.features[1].properties?.iconId).toBe('icon-liveua-yellow');
|
||||
});
|
||||
it('classifies violent incidents with red icon', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Missile strike in Kharkiv', date: '2024-01-01' },
|
||||
{ id: '2', lat: 49.0, lng: 36.0, title: 'Humanitarian aid delivery', date: '2024-01-01' },
|
||||
];
|
||||
const result = buildLiveuaGeoJSON(incidents);
|
||||
expect(result!.features).toHaveLength(2);
|
||||
expect(result!.features[0].properties?.iconId).toBe('icon-liveua-red');
|
||||
expect(result!.features[1].properties?.iconId).toBe('icon-liveua-yellow');
|
||||
});
|
||||
|
||||
it('filters by inView when provided', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Test', date: '2024-01-01' },
|
||||
{ id: '2', lat: 10.0, lng: 20.0, title: 'Far away', date: '2024-01-01' },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildLiveuaGeoJSON(incidents, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters by inView when provided', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Test', date: '2024-01-01' },
|
||||
{ id: '2', lat: 10.0, lng: 20.0, title: 'Far away', date: '2024-01-01' },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildLiveuaGeoJSON(incidents, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Frontline ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFrontlineGeoJSON', () => {
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(buildFrontlineGeoJSON(null)).toBeNull();
|
||||
expect(buildFrontlineGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(buildFrontlineGeoJSON(null)).toBeNull();
|
||||
expect(buildFrontlineGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the input unchanged when valid', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [{ type: 'Feature' as const, properties: { name: 'zone', zone_id: 1 }, geometry: { type: 'Polygon' as const, coordinates: [[[30, 48], [31, 49], [30, 49], [30, 48]]] as [number, number][][] } }] };
|
||||
const result = buildFrontlineGeoJSON(fc);
|
||||
expect(result).toBe(fc); // Same reference — passthrough
|
||||
});
|
||||
it('returns the input unchanged when valid', () => {
|
||||
const fc = {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
properties: { name: 'zone', zone_id: 1 },
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [
|
||||
[
|
||||
[30, 48],
|
||||
[31, 49],
|
||||
[30, 49],
|
||||
[30, 48],
|
||||
],
|
||||
] as [number, number][][],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildFrontlineGeoJSON(fc);
|
||||
expect(result).toBe(fc); // Same reference — passthrough
|
||||
});
|
||||
|
||||
it('returns null for empty features array', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [] };
|
||||
expect(buildFrontlineGeoJSON(fc)).toBeNull();
|
||||
});
|
||||
it('returns null for empty features array', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [] };
|
||||
expect(buildFrontlineGeoJSON(fc)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scanners ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildScannerGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildScannerGeoJSON(undefined)).toBeNull();
|
||||
expect(buildScannerGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with scanner properties', () => {
|
||||
const scanners: Scanner[] = [
|
||||
{
|
||||
shortName: 'TEST',
|
||||
name: 'Test System',
|
||||
lat: 39.0,
|
||||
lng: -104.0,
|
||||
city: 'Denver',
|
||||
state: 'CO',
|
||||
clientCount: 5,
|
||||
description: 'Demo',
|
||||
},
|
||||
];
|
||||
const result = buildScannerGeoJSON(scanners);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('scanner');
|
||||
expect(result!.features[0].properties?.name).toBe('Test System');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
gateEnvelopeDisplayText,
|
||||
gateEnvelopeState,
|
||||
isEncryptedGateEnvelope,
|
||||
} from '@/mesh/gateEnvelope';
|
||||
import { normalizePayload } from '@/mesh/meshProtocol';
|
||||
import { validateEventPayload } from '@/mesh/meshSchema';
|
||||
|
||||
describe('gate envelope protocol', () => {
|
||||
it('normalizes encrypted gate-message payloads', () => {
|
||||
expect(
|
||||
normalizePayload('gate_message', {
|
||||
gate: 'Finance',
|
||||
epoch: '2',
|
||||
ciphertext: 'opaque',
|
||||
nonce: 'nonce-2',
|
||||
sender_ref: 'persona-fin-1',
|
||||
}),
|
||||
).toEqual({
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'opaque',
|
||||
nonce: 'nonce-2',
|
||||
sender_ref: 'persona-fin-1',
|
||||
format: 'g1',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts encrypted gate-message envelopes and rejects plaintext ones', () => {
|
||||
expect(
|
||||
validateEventPayload('gate_message', {
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'opaque',
|
||||
nonce: 'nonce-2',
|
||||
sender_ref: 'persona-fin-1',
|
||||
format: 'g1',
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
expect(
|
||||
validateEventPayload('gate_message', {
|
||||
gate: 'finance',
|
||||
message: 'plaintext',
|
||||
}),
|
||||
).toEqual({ ok: false, reason: 'Payload is not normalized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate envelope display', () => {
|
||||
it('detects encrypted gate messages and shows placeholders honestly', () => {
|
||||
const encrypted = {
|
||||
event_type: 'gate_message',
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'opaque',
|
||||
nonce: 'nonce-2',
|
||||
sender_ref: 'persona-fin-1',
|
||||
};
|
||||
|
||||
expect(isEncryptedGateEnvelope(encrypted)).toBe(true);
|
||||
expect(gateEnvelopeState(encrypted)).toBe('locked');
|
||||
expect(gateEnvelopeDisplayText(encrypted)).toBe('ENCRYPTED GATE MESSAGE - KEY UNAVAILABLE');
|
||||
expect(
|
||||
gateEnvelopeState({
|
||||
...encrypted,
|
||||
decrypted_message: 'decoded text',
|
||||
}),
|
||||
).toBe('decrypted');
|
||||
expect(
|
||||
gateEnvelopeDisplayText({
|
||||
...encrypted,
|
||||
decrypted_message: 'decoded text',
|
||||
}),
|
||||
).toBe('decoded text');
|
||||
expect(
|
||||
gateEnvelopeState({
|
||||
event_type: 'gate_notice',
|
||||
message: 'legacy plaintext',
|
||||
}),
|
||||
).toBe('plaintext');
|
||||
expect(
|
||||
gateEnvelopeDisplayText({
|
||||
event_type: 'gate_notice',
|
||||
message: 'legacy plaintext',
|
||||
}),
|
||||
).toBe('legacy plaintext');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const deadDropTokensForContacts = vi.fn();
|
||||
const mailboxClaimToken = vi.fn();
|
||||
const mailboxDecoySharedToken = vi.fn();
|
||||
|
||||
vi.mock('@/mesh/meshDeadDrop', () => ({
|
||||
deadDropToken: vi.fn(),
|
||||
deadDropTokensForContacts,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshMailbox', () => ({
|
||||
mailboxClaimToken,
|
||||
mailboxDecoySharedToken,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
deriveSenderSealKey: vi.fn(),
|
||||
ensureDhKeysFresh: vi.fn(),
|
||||
deriveSharedKey: vi.fn(),
|
||||
encryptDM: vi.fn(),
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
getNodeIdentity: vi.fn(() => ({ nodeId: '!self', publicKey: 'pub' })),
|
||||
getPublicKeyAlgo: vi.fn(() => 'Ed25519'),
|
||||
nextSequence: vi.fn(() => 1),
|
||||
verifyNodeIdBindingFromPublicKey: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
buildWormholeSenderSeal: vi.fn(),
|
||||
getActiveSigningContext: vi.fn(async () => null),
|
||||
isWormholeSecureRequired: vi.fn(async () => false),
|
||||
issueWormholeDmSenderToken: vi.fn(),
|
||||
issueWormholeDmSenderTokens: vi.fn(),
|
||||
registerWormholeDmKey: vi.fn(),
|
||||
signRawMeshMessage: vi.fn(),
|
||||
signMeshEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshSchema', () => ({
|
||||
validateEventPayload: vi.fn(() => ({ ok: true, reason: 'ok' })),
|
||||
}));
|
||||
|
||||
describe('mailbox claim privacy padding', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv('NEXT_PUBLIC_ENABLE_RFC2A_CLAIM_SHAPE', '1');
|
||||
deadDropTokensForContacts.mockReset();
|
||||
mailboxClaimToken.mockReset();
|
||||
mailboxDecoySharedToken.mockReset();
|
||||
mailboxClaimToken.mockImplementation(async (type: string) => `${type}-token`);
|
||||
mailboxDecoySharedToken.mockImplementation(async (index: number) => `decoy-${index}`);
|
||||
});
|
||||
|
||||
function buildSharedTokens(count: number): string[] {
|
||||
return Array.from({ length: count }, (_, index) => `shared-${index + 1}`);
|
||||
}
|
||||
|
||||
it('uses bucketed shared-claim envelopes across multiple contact counts', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
for (const testCase of [
|
||||
{ realSharedClaims: 0, expectedSharedClaims: 3, expectedTotalClaims: 5 },
|
||||
{ realSharedClaims: 1, expectedSharedClaims: 3, expectedTotalClaims: 5 },
|
||||
{ realSharedClaims: 3, expectedSharedClaims: 3, expectedTotalClaims: 5 },
|
||||
{ realSharedClaims: 4, expectedSharedClaims: 6, expectedTotalClaims: 8 },
|
||||
{ realSharedClaims: 7, expectedSharedClaims: 12, expectedTotalClaims: 14 },
|
||||
{ realSharedClaims: 25, expectedSharedClaims: 30, expectedTotalClaims: 32 },
|
||||
{ realSharedClaims: 30, expectedSharedClaims: 30, expectedTotalClaims: 32 },
|
||||
]) {
|
||||
deadDropTokensForContacts.mockResolvedValue(buildSharedTokens(testCase.realSharedClaims));
|
||||
|
||||
const claims = await mod.buildMailboxClaims({});
|
||||
expect(claims.slice(0, 2)).toEqual([
|
||||
{ type: 'self', token: 'self-token' },
|
||||
{ type: 'requests', token: 'requests-token' },
|
||||
]);
|
||||
expect(claims.filter((claim) => claim.type === 'shared')).toHaveLength(
|
||||
testCase.expectedSharedClaims,
|
||||
);
|
||||
expect(claims).toHaveLength(testCase.expectedTotalClaims);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the legacy shared-claim floor when the experiment is disabled', async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('NEXT_PUBLIC_ENABLE_RFC2A_CLAIM_SHAPE', '0');
|
||||
deadDropTokensForContacts.mockResolvedValue(['shared-1', 'shared-2', 'shared-3', 'shared-4']);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = await mod.buildMailboxClaims({});
|
||||
const sharedClaims = claims.filter((claim) => claim.type === 'shared');
|
||||
|
||||
expect(mod.MAILBOX_SHARED_CLAIM_SHAPE_VERSION).toBe('legacy-floor-v1');
|
||||
expect(sharedClaims).toEqual([
|
||||
{ type: 'shared', token: 'shared-1' },
|
||||
{ type: 'shared', token: 'shared-2' },
|
||||
{ type: 'shared', token: 'shared-3' },
|
||||
{ type: 'shared', token: 'shared-4' },
|
||||
]);
|
||||
expect(claims).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('deduplicates real shared tokens before filling the bucketed envelope', async () => {
|
||||
deadDropTokensForContacts.mockResolvedValue(['shared-real', 'shared-real']);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = await mod.buildMailboxClaims({
|
||||
alice: { blocked: false, dhPubKey: 'dh-a' },
|
||||
});
|
||||
|
||||
const sharedClaims = claims.filter((claim) => claim.type === 'shared');
|
||||
expect(sharedClaims).toEqual([
|
||||
{ type: 'shared', token: 'decoy-0' },
|
||||
{ type: 'shared', token: 'shared-real' },
|
||||
{ type: 'shared', token: 'decoy-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves every real shared token within the supported 30-claim shared range', async () => {
|
||||
const realSharedTokens = buildSharedTokens(30);
|
||||
deadDropTokensForContacts.mockResolvedValue(realSharedTokens);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = await mod.buildMailboxClaims({});
|
||||
|
||||
const sharedTokens = claims
|
||||
.filter((claim) => claim.type === 'shared')
|
||||
.map((claim) => claim.token);
|
||||
expect(sharedTokens).toHaveLength(30);
|
||||
expect(new Set(sharedTokens)).toEqual(new Set(realSharedTokens));
|
||||
});
|
||||
|
||||
it('keeps decoy shared tokens distinct from real shared tokens', async () => {
|
||||
const realSharedTokens = ['shared-1', 'shared-2', 'shared-3', 'shared-4'];
|
||||
deadDropTokensForContacts.mockResolvedValue(realSharedTokens);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = await mod.buildMailboxClaims({});
|
||||
|
||||
const sharedTokens = claims
|
||||
.filter((claim) => claim.type === 'shared')
|
||||
.map((claim) => String(claim.token || ''));
|
||||
const decoyTokens = sharedTokens.filter((token) => !realSharedTokens.includes(token));
|
||||
|
||||
expect(sharedTokens).toEqual([
|
||||
'shared-1',
|
||||
'decoy-0',
|
||||
'shared-2',
|
||||
'shared-3',
|
||||
'decoy-1',
|
||||
'shared-4',
|
||||
]);
|
||||
expect(decoyTokens).toEqual(['decoy-0', 'decoy-1']);
|
||||
expect(decoyTokens.every((token) => !realSharedTokens.includes(token))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { buildSignaturePayload, type JsonValue } from '@/mesh/meshProtocol';
|
||||
|
||||
type Fixture = {
|
||||
name: string;
|
||||
event_type: string;
|
||||
node_id: string;
|
||||
sequence: number;
|
||||
payload: Record<string, JsonValue>;
|
||||
expected: string;
|
||||
};
|
||||
|
||||
describe('mesh canonical signature payloads', () => {
|
||||
const cwd = process.cwd();
|
||||
const fixturePath = cwd.endsWith('frontend')
|
||||
? path.resolve(cwd, '..', 'docs', 'mesh', 'mesh-canonical-fixtures.json')
|
||||
: path.resolve(cwd, 'docs', 'mesh', 'mesh-canonical-fixtures.json');
|
||||
const fixtures = JSON.parse(readFileSync(fixturePath, 'utf-8')) as Fixture[];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
it(`matches fixture: ${fixture.name}`, () => {
|
||||
const result = buildSignaturePayload({
|
||||
eventType: fixture.event_type,
|
||||
nodeId: fixture.node_id,
|
||||
sequence: fixture.sequence,
|
||||
payload: fixture.payload,
|
||||
});
|
||||
expect(result).toBe(fixture.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const idbStore = new Map<string, unknown>();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn(async (id: string) => idbStore.get(id) ?? null),
|
||||
setKey: vi.fn(async (id: string, key: unknown) => {
|
||||
idbStore.set(id, key);
|
||||
}),
|
||||
deleteKey: vi.fn(async (id: string) => {
|
||||
idbStore.delete(id);
|
||||
}),
|
||||
}));
|
||||
|
||||
async function flushStoragePersistence(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitForEncryptedContacts(): Promise<string | null> {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await flushStoragePersistence();
|
||||
const stored =
|
||||
sessionStorage.getItem('sb_mesh_contacts') || localStorage.getItem('sb_mesh_contacts');
|
||||
if (typeof stored === 'string' && stored.startsWith('enc:')) {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return sessionStorage.getItem('sb_mesh_contacts') || localStorage.getItem('sb_mesh_contacts');
|
||||
}
|
||||
|
||||
function bufToBase64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
describe('meshIdentity contact storage hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
idbStore.clear();
|
||||
const makeStorage = () => {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
};
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
async function provisionLocalIdentity(mod: typeof import('@/mesh/meshIdentity')) {
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_contacts123456');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
const keyPair = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveKey', 'deriveBits'],
|
||||
)) as CryptoKeyPair;
|
||||
const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
localStorage.setItem('sb_mesh_dh_pubkey', bufToBase64(publicRaw));
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
idbStore.set('sb_mesh_dh_priv', keyPair.privateKey);
|
||||
}
|
||||
|
||||
it('hydrates secure-mode contacts from Wormhole and avoids localStorage persistence', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
contacts: {
|
||||
alice: { blocked: false, dhPubKey: 'dh_a', sharedAlias: 'alias_a' },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: 'alice',
|
||||
contact: { blocked: false, dhPubKey: 'dh_a2', sharedAlias: 'alias_a' },
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
mod.setSecureModeCached(true);
|
||||
|
||||
const contacts = await mod.hydrateWormholeContacts(true);
|
||||
expect(contacts.alice.dhPubKey).toBe('dh_a');
|
||||
|
||||
mod.addContact('alice', 'dh_a2');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorage.getItem('sb_mesh_contacts')).toBeNull();
|
||||
expect(mod.getContacts().alice.dhPubKey).toBe('dh_a2');
|
||||
expect(controlPlaneJson).toHaveBeenLastCalledWith('/api/wormhole/dm/contact', expect.any(Object));
|
||||
});
|
||||
|
||||
it('stores local contacts as encrypted ciphertext and hydrates them back', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
mod.addContact('alice', 'dh_a', 'Alice', 'X25519');
|
||||
mod.updateContact('alice', {
|
||||
remotePrekeyFingerprint: 'fp-1',
|
||||
remotePrekeyObservedFingerprint: 'fp-1',
|
||||
remotePrekeyPinnedAt: 111,
|
||||
remotePrekeyLastSeenAt: 222,
|
||||
remotePrekeySequence: 3,
|
||||
remotePrekeySignedAt: 444,
|
||||
remotePrekeyMismatch: false,
|
||||
});
|
||||
const stored = await waitForEncryptedContacts();
|
||||
expect(String(stored ?? '')).toMatch(/^enc:/);
|
||||
expect(String(stored ?? '')).not.toContain('"alice"');
|
||||
expect(String(stored ?? '')).not.toContain('"dh_a"');
|
||||
|
||||
const hydrated = await mod.hydrateWormholeContacts(true);
|
||||
expect(hydrated.alice.dhPubKey).toBe('dh_a');
|
||||
expect(hydrated.alice.alias).toBe('Alice');
|
||||
expect(hydrated.alice.remotePrekeyFingerprint).toBe('fp-1');
|
||||
expect(hydrated.alice.remotePrekeyObservedFingerprint).toBe('fp-1');
|
||||
expect(hydrated.alice.remotePrekeyPinnedAt).toBe(111);
|
||||
expect(hydrated.alice.remotePrekeyLastSeenAt).toBe(222);
|
||||
expect(hydrated.alice.remotePrekeySequence).toBe(3);
|
||||
expect(hydrated.alice.remotePrekeySignedAt).toBe(444);
|
||||
expect(hydrated.alice.remotePrekeyMismatch).toBe(false);
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext contacts to encrypted storage on first hydrate', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
localStorage.setItem(
|
||||
'sb_mesh_contacts',
|
||||
JSON.stringify({ alice: { blocked: false, dhPubKey: 'legacy_dh', alias: 'Legacy Alice' } }),
|
||||
);
|
||||
|
||||
const hydrated = await mod.hydrateWormholeContacts(true);
|
||||
expect(hydrated.alice.dhPubKey).toBe('legacy_dh');
|
||||
expect(hydrated.alice.alias).toBe('Legacy Alice');
|
||||
|
||||
const stored = await waitForEncryptedContacts();
|
||||
expect(String(stored ?? '')).toMatch(/^enc:/);
|
||||
expect(String(stored ?? '')).not.toContain('legacy_dh');
|
||||
});
|
||||
|
||||
it('encrypts identity-bound browser payloads under distinct info domains', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
const accessCipher = await mod.encryptIdentityBoundStoragePayload(
|
||||
[{ sender_id: 'alice', timestamp: 1 }],
|
||||
'SB-ACCESS-REQUESTS-STORAGE-V1',
|
||||
);
|
||||
expect(accessCipher).toMatch(/^enc:/);
|
||||
expect(accessCipher).not.toContain('alice');
|
||||
|
||||
const decrypted = await mod.decryptIdentityBoundStoragePayload(
|
||||
accessCipher,
|
||||
'SB-ACCESS-REQUESTS-STORAGE-V1',
|
||||
[],
|
||||
);
|
||||
expect(decrypted).toEqual([{ sender_id: 'alice', timestamp: 1 }]);
|
||||
|
||||
await expect(
|
||||
mod.decryptIdentityBoundStoragePayload(
|
||||
accessCipher,
|
||||
'SB-PENDING-CONTACTS-STORAGE-V1',
|
||||
[],
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('treats unreadable encrypted contacts as empty and warns instead of crashing', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
localStorage.setItem('sb_mesh_contacts', 'enc:not-valid-ciphertext');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const hydrated = await mod.hydrateWormholeContacts(true);
|
||||
|
||||
expect(hydrated).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[mesh] contact storage unreadable — treating as empty contacts',
|
||||
expect.anything(),
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('purges browser-persisted contact graph when secure mode boundary is applied', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
localStorage.setItem(
|
||||
'sb_mesh_contacts',
|
||||
JSON.stringify({ bob: { blocked: false, sharedAlias: 'peer-b' } }),
|
||||
);
|
||||
|
||||
mod.purgeBrowserContactGraph();
|
||||
|
||||
expect(localStorage.getItem('sb_mesh_contacts')).toBeNull();
|
||||
expect(mod.getContacts()).toEqual({});
|
||||
});
|
||||
|
||||
it('rotates the mailbox-claim secret when identity state is cleared', async () => {
|
||||
const { mailboxClaimToken } = await import('@/mesh/meshMailbox');
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
const first = await mailboxClaimToken('requests', '!sb_contacts123456');
|
||||
const second = await mailboxClaimToken('requests', '!sb_contacts123456');
|
||||
expect(second).toBe(first);
|
||||
|
||||
await mod.clearBrowserIdentityState();
|
||||
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_contacts123456');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
const keyPair = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveKey', 'deriveBits'],
|
||||
)) as CryptoKeyPair;
|
||||
const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
localStorage.setItem('sb_mesh_dh_pubkey', bufToBase64(publicRaw));
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
idbStore.set('sb_mesh_dh_priv', keyPair.privateKey);
|
||||
|
||||
const rotated = await mailboxClaimToken('requests', '!sb_contacts123456');
|
||||
expect(rotated).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
allDmPeerIds,
|
||||
buildAliasRotateMessage,
|
||||
buildAccessGrantedMessage,
|
||||
buildContactAcceptMessage,
|
||||
buildContactDenyMessage,
|
||||
buildContactOfferMessage,
|
||||
mergeAliasHistory,
|
||||
parseAliasRotateMessage,
|
||||
parseAccessGrantedMessage,
|
||||
parseDmConsentMessage,
|
||||
preferredDmPeerId,
|
||||
} from '@/mesh/meshDmConsent';
|
||||
|
||||
describe('mesh DM consent helpers', () => {
|
||||
it('builds and parses access-granted payloads', () => {
|
||||
const message = buildAccessGrantedMessage('dmx_alpha');
|
||||
expect(parseAccessGrantedMessage(message)).toEqual({ shared_alias: 'dmx_alpha' });
|
||||
});
|
||||
|
||||
it('builds and parses off-ledger contact offer payloads', () => {
|
||||
const message = buildContactOfferMessage('dh_pub', 'X25519', '40.12,-105.27');
|
||||
expect(parseDmConsentMessage(message)).toEqual({
|
||||
kind: 'contact_offer',
|
||||
dh_pub_key: 'dh_pub',
|
||||
dh_algo: 'X25519',
|
||||
geo_hint: '40.12,-105.27',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds and parses off-ledger contact accept payloads', () => {
|
||||
const message = buildContactAcceptMessage('dmx_pairwise');
|
||||
expect(parseDmConsentMessage(message)).toEqual({
|
||||
kind: 'contact_accept',
|
||||
shared_alias: 'dmx_pairwise',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds and parses off-ledger contact deny payloads', () => {
|
||||
const message = buildContactDenyMessage('declined');
|
||||
expect(parseDmConsentMessage(message)).toEqual({
|
||||
kind: 'contact_deny',
|
||||
reason: 'declined',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the pairwise alias for shared DM routing', () => {
|
||||
expect(preferredDmPeerId('node_public', { sharedAlias: 'dmx_pairwise' })).toBe('dmx_pairwise');
|
||||
expect(preferredDmPeerId('node_public', { sharedAlias: '' })).toBe('node_public');
|
||||
});
|
||||
|
||||
it('keeps both alias and public ids during the transition window', () => {
|
||||
expect(allDmPeerIds('node_public', { sharedAlias: 'dmx_pairwise' })).toEqual([
|
||||
'dmx_pairwise',
|
||||
'node_public',
|
||||
]);
|
||||
expect(allDmPeerIds('node_public', { sharedAlias: 'node_public' })).toEqual(['node_public']);
|
||||
});
|
||||
|
||||
it('builds and parses alias rotation control payloads', () => {
|
||||
const message = buildAliasRotateMessage('dmx_next');
|
||||
expect(parseAliasRotateMessage(message)).toEqual({ shared_alias: 'dmx_next' });
|
||||
});
|
||||
|
||||
it('promotes pending alias after the grace window elapses', () => {
|
||||
const now = Date.now();
|
||||
expect(
|
||||
preferredDmPeerId('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
sharedAliasGraceUntil: now - 1,
|
||||
}),
|
||||
).toBe('dmx_next');
|
||||
});
|
||||
|
||||
it('keeps alias history compact and unique', () => {
|
||||
expect(mergeAliasHistory(['dmx_a', 'dmx_b', 'dmx_a', 'dmx_c', 'dmx_d'], 3)).toEqual([
|
||||
'dmx_a',
|
||||
'dmx_b',
|
||||
'dmx_c',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type StoreRecord = Map<string, unknown>;
|
||||
type DbRecord = {
|
||||
version: number;
|
||||
stores: Map<string, StoreRecord>;
|
||||
};
|
||||
|
||||
const databases = new Map<string, DbRecord>();
|
||||
const deletedDatabases: string[] = [];
|
||||
const workerInstances: FakeWorker[] = [];
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
ensureWormholeReadyForSecureAction: vi.fn(async () => undefined),
|
||||
isWormholeReady: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
}));
|
||||
|
||||
function makeStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
key: (_i: number) => null as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeWorker {
|
||||
onmessage: ((event: MessageEvent<{ id: string; ok: boolean; result?: string }>) => void) | null =
|
||||
null;
|
||||
terminated = false;
|
||||
|
||||
constructor() {
|
||||
workerInstances.push(this);
|
||||
}
|
||||
|
||||
postMessage(message: { id: string }) {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: { id: message.id, ok: true, result: '' },
|
||||
} as MessageEvent<{ id: string; ok: boolean; result?: string }>);
|
||||
});
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
function domStringList(record: DbRecord): DOMStringList {
|
||||
return {
|
||||
contains: (name: string) => record.stores.has(name),
|
||||
item: (index: number) => Array.from(record.stores.keys())[index] ?? null,
|
||||
get length() {
|
||||
return record.stores.size;
|
||||
},
|
||||
} as DOMStringList;
|
||||
}
|
||||
|
||||
function makeRequest<T>(
|
||||
executor: (request: IDBRequest<T>) => void,
|
||||
tx?: IDBTransaction,
|
||||
): IDBRequest<T> {
|
||||
const request = {} as IDBRequest<T>;
|
||||
queueMicrotask(() => {
|
||||
executor(request);
|
||||
tx?.oncomplete?.(new Event('complete') as Event);
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
function makeObjectStore(record: DbRecord, name: string, tx: IDBTransaction): IDBObjectStore {
|
||||
const store = record.stores.get(name);
|
||||
if (!store) throw new Error(`missing object store ${name}`);
|
||||
return {
|
||||
get(key: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
(request as { result?: unknown }).result = store.get(String(key));
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
put(value: unknown, key?: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
store.set(String(key ?? ''), value);
|
||||
(request as { result?: unknown }).result = key;
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
delete(key: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
store.delete(String(key));
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
} as unknown as IDBObjectStore;
|
||||
}
|
||||
|
||||
function makeTransaction(record: DbRecord): IDBTransaction {
|
||||
const tx = {
|
||||
oncomplete: null,
|
||||
onerror: null,
|
||||
onabort: null,
|
||||
objectStore: (name: string) => makeObjectStore(record, name, tx as unknown as IDBTransaction),
|
||||
} as unknown as IDBTransaction;
|
||||
return tx;
|
||||
}
|
||||
|
||||
function makeDb(name: string, record: DbRecord): IDBDatabase {
|
||||
return {
|
||||
name,
|
||||
version: record.version,
|
||||
objectStoreNames: domStringList(record),
|
||||
createObjectStore(storeName: string) {
|
||||
if (!record.stores.has(storeName)) {
|
||||
record.stores.set(storeName, new Map());
|
||||
}
|
||||
return {} as IDBObjectStore;
|
||||
},
|
||||
transaction(storeName: string | string[]) {
|
||||
return makeTransaction(record);
|
||||
},
|
||||
close() {
|
||||
/* noop */
|
||||
},
|
||||
} as unknown as IDBDatabase;
|
||||
}
|
||||
|
||||
function createFakeIndexedDb() {
|
||||
return {
|
||||
open(name: string, version?: number) {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
const resolvedVersion = Number(version || 1);
|
||||
let record = databases.get(name);
|
||||
const upgrading = !record || resolvedVersion > record.version;
|
||||
if (!record) {
|
||||
record = { version: resolvedVersion, stores: new Map() };
|
||||
databases.set(name, record);
|
||||
}
|
||||
if (upgrading) {
|
||||
record.version = resolvedVersion;
|
||||
(request as { result?: IDBDatabase }).result = makeDb(name, record);
|
||||
request.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent);
|
||||
}
|
||||
(request as { result?: IDBDatabase }).result = makeDb(name, record);
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
},
|
||||
deleteDatabase(name: string) {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
deletedDatabases.push(name);
|
||||
databases.delete(name);
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureStore(name: string, version: number, storeName: string): StoreRecord {
|
||||
let record = databases.get(name);
|
||||
if (!record) {
|
||||
record = { version, stores: new Map() };
|
||||
databases.set(name, record);
|
||||
}
|
||||
record.version = Math.max(record.version, version);
|
||||
if (!record.stores.has(storeName)) {
|
||||
record.stores.set(storeName, new Map());
|
||||
}
|
||||
return record.stores.get(storeName)!;
|
||||
}
|
||||
|
||||
function getStoredValue(name: string, storeName: string, key: string): unknown {
|
||||
return databases.get(name)?.stores.get(storeName)?.get(key);
|
||||
}
|
||||
|
||||
describe('worker ratchet vault hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
databases.clear();
|
||||
deletedDatabases.length = 0;
|
||||
workerInstances.length = 0;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Worker', {
|
||||
value: FakeWorker,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
value: createFakeIndexedDb(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists worker ratchet state as an encrypted blob instead of raw state', async () => {
|
||||
const mod = await import('@/mesh/meshDmWorkerVault');
|
||||
const sample = {
|
||||
alice: {
|
||||
algo: 'X25519',
|
||||
rk: 'root-key',
|
||||
cks: 'send-chain',
|
||||
ckr: 'recv-chain',
|
||||
dhSelfPub: 'pub',
|
||||
dhSelfPriv: 'private-material',
|
||||
dhRemote: 'remote',
|
||||
ns: 1,
|
||||
nr: 2,
|
||||
pn: 3,
|
||||
skipped: { 'remote:1': 'mk' },
|
||||
updated: 123,
|
||||
},
|
||||
};
|
||||
|
||||
await mod.writeWorkerRatchetStates(sample);
|
||||
|
||||
const raw = getStoredValue(mod.WORKER_RATCHET_DB, 'ratchet', 'state');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('dhSelfPriv');
|
||||
expect(String(raw)).not.toContain('private-material');
|
||||
|
||||
const loaded = await mod.readWorkerRatchetStates();
|
||||
expect(loaded).toEqual(sample);
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext worker state into encrypted storage on read', async () => {
|
||||
const legacyStore = ensureStore('sb_mesh_dm_worker', 1, 'ratchet');
|
||||
legacyStore.set('state', {
|
||||
bob: {
|
||||
algo: 'X25519',
|
||||
rk: 'legacy-rk',
|
||||
dhSelfPub: 'legacy-pub',
|
||||
dhSelfPriv: 'legacy-private',
|
||||
dhRemote: 'legacy-remote',
|
||||
ns: 0,
|
||||
nr: 0,
|
||||
pn: 0,
|
||||
updated: 999,
|
||||
},
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshDmWorkerVault');
|
||||
const loaded = await mod.readWorkerRatchetStates();
|
||||
const raw = getStoredValue(mod.WORKER_RATCHET_DB, 'ratchet', 'state');
|
||||
|
||||
expect(loaded.bob?.dhSelfPriv).toBe('legacy-private');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('legacy-private');
|
||||
});
|
||||
|
||||
it('purgeBrowserDmState clears worker persistence and legacy browser copies', async () => {
|
||||
localStorage.setItem('sb_mesh_dm_ratchet', 'legacy');
|
||||
sessionStorage.setItem('sb_mesh_ratchet_telemetry', '{"seen":1}');
|
||||
const mod = await import('@/mesh/meshDmWorkerClient');
|
||||
|
||||
await mod.purgeBrowserDmState();
|
||||
|
||||
expect(localStorage.getItem('sb_mesh_dm_ratchet')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_ratchet_telemetry')).toBeNull();
|
||||
expect(deletedDatabases).toContain('sb_mesh_dm_worker');
|
||||
expect(workerInstances[0]?.terminated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn().mockResolvedValue(null),
|
||||
setKey: vi.fn().mockResolvedValue(undefined),
|
||||
deleteKey: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('mesh identity storage separation', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
const makeStorage = () => {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
};
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps public browser identity separate from Wormhole descriptor cache', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: '!sb_public',
|
||||
publicKey: 'public-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
mod.cacheWormholeIdentityDescriptor({
|
||||
nodeId: '!sb_wormhole',
|
||||
publicKey: 'wormhole-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
expect(mod.getStoredNodeDescriptor()).toEqual({
|
||||
nodeId: '!sb_public',
|
||||
publicKey: 'public-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
expect(mod.getWormholeIdentityDescriptor()).toEqual({
|
||||
nodeId: '!sb_wormhole',
|
||||
publicKey: 'wormhole-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears browser public identity and Wormhole descriptor cache together on full reset', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: '!sb_public',
|
||||
publicKey: 'public-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
mod.cacheWormholeIdentityDescriptor({
|
||||
nodeId: '!sb_wormhole',
|
||||
publicKey: 'wormhole-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
await mod.clearBrowserIdentityState();
|
||||
|
||||
expect(mod.getStoredNodeDescriptor()).toBeNull();
|
||||
expect(mod.getWormholeIdentityDescriptor()).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates legacy browser and Wormhole node ids to the current format', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const publicKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
const currentNodeId = await mod.deriveNodeIdFromPublicKey(publicKey);
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: '!sb_deadbeef',
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
mod.cacheWormholeIdentityDescriptor({
|
||||
nodeId: '!sb_deadbeef',
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
await mod.migrateLegacyNodeIds();
|
||||
|
||||
expect(mod.getStoredNodeDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
expect(mod.getWormholeIdentityDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { buildMerkleRoot, verifyMerkleProof } from '@/mesh/meshMerkle';
|
||||
|
||||
type Fixture = {
|
||||
leaves: string[];
|
||||
root: string;
|
||||
proofs: Record<string, { hash: string; side: string }[]>;
|
||||
};
|
||||
|
||||
describe('mesh merkle fixtures', () => {
|
||||
const cwd = process.cwd();
|
||||
const fixturePath = cwd.endsWith('frontend')
|
||||
? path.resolve(cwd, '..', 'docs', 'mesh', 'mesh-merkle-fixtures.json')
|
||||
: path.resolve(cwd, 'docs', 'mesh', 'mesh-merkle-fixtures.json');
|
||||
const fixtures = JSON.parse(readFileSync(fixturePath, 'utf-8')) as Fixture;
|
||||
|
||||
it('builds the expected root', async () => {
|
||||
const root = await buildMerkleRoot(fixtures.leaves);
|
||||
expect(root).toBe(fixtures.root);
|
||||
});
|
||||
|
||||
it('verifies provided proofs', async () => {
|
||||
const root = fixtures.root;
|
||||
for (const [idxStr, proof] of Object.entries(fixtures.proofs)) {
|
||||
const idx = Number(idxStr);
|
||||
const leaf = fixtures.leaves[idx];
|
||||
const ok = await verifyMerkleProof(leaf, idx, proof, root);
|
||||
expect(ok).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildDmTrustHint,
|
||||
buildPrivateLaneHint,
|
||||
dmTrustPrimaryActionLabel,
|
||||
isFirstContactTrustOnly,
|
||||
shortTrustFingerprint,
|
||||
shouldAutoRevealSasForTrust,
|
||||
} from '@/mesh/meshPrivacyHints';
|
||||
|
||||
describe('meshPrivacyHints', () => {
|
||||
it('flags recent private-lane fallback as a danger hint', () => {
|
||||
const hint = buildPrivateLaneHint({
|
||||
activeTab: 'dms',
|
||||
recentPrivateFallback: true,
|
||||
recentPrivateFallbackReason: 'Tor transport failed and clearnet relay was used.',
|
||||
dmTransportMode: 'relay',
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'RECENT PRIVACY DOWNGRADE',
|
||||
}),
|
||||
);
|
||||
expect(hint?.detail).toContain('clearnet relay');
|
||||
});
|
||||
|
||||
it('flags remote prekey mismatch as a danger trust hint', () => {
|
||||
const hint = buildDmTrustHint({
|
||||
remotePrekeyMismatch: true,
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'REMOTE PREKEY CHANGED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('flags first-seen pinned contacts as TOFU until verified', () => {
|
||||
const contact = {
|
||||
remotePrekeyFingerprint: 'abc123',
|
||||
remotePrekeyPinnedAt: 123,
|
||||
verify_registry: false,
|
||||
verify_inband: false,
|
||||
verified: false,
|
||||
};
|
||||
|
||||
expect(isFirstContactTrustOnly(contact)).toBe(true);
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'FIRST CONTACT (TOFU ONLY)',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('not proof of sender identity');
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('VERIFY SAS NOW');
|
||||
expect(shouldAutoRevealSasForTrust(contact)).toBe(true);
|
||||
});
|
||||
|
||||
it('auto-reveals SAS for trust hazards but keeps ordinary verified contacts quiet', () => {
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
remotePrekeyMismatch: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
verify_mismatch: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe('SHOW SAS');
|
||||
});
|
||||
|
||||
it('shortens long trust fingerprints for display', () => {
|
||||
expect(shortTrustFingerprint('abcdef0123456789fedcba9876543210')).toBe('abcdef01..543210');
|
||||
expect(shortTrustFingerprint('abcd1234')).toBe('abcd1234');
|
||||
expect(shortTrustFingerprint('')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Track what gets stored in IndexedDB
|
||||
const idbStore = new Map<string, unknown>();
|
||||
const deletedDatabases: string[] = [];
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn(async (id: string) => idbStore.get(id) ?? null),
|
||||
setKey: vi.fn(async (id: string, key: unknown) => {
|
||||
idbStore.set(id, key);
|
||||
}),
|
||||
deleteKey: vi.fn(async (id: string) => {
|
||||
idbStore.delete(id);
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
key: (_i: number) => null as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('signing key storage hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
idbStore.clear();
|
||||
deletedDatabases.length = 0;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
value: {
|
||||
deleteDatabase: vi.fn((name: string) => {
|
||||
deletedDatabases.push(name);
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
}),
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('getNodeIdentity returns identity even when privateKey is empty (post-migration)', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
// Simulate a state where the signing key has already been migrated:
|
||||
// publicKey and nodeId exist, but privateKey does not.
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_abcd1234abcd1234');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
// No sb_mesh_privkey — simulates post-migration state
|
||||
|
||||
const identity = mod.getNodeIdentity();
|
||||
expect(identity).not.toBeNull();
|
||||
expect(identity!.publicKey).toBe('test-pub');
|
||||
expect(identity!.nodeId).toBe('!sb_abcd1234abcd1234');
|
||||
expect(identity!.privateKey).toBe('');
|
||||
});
|
||||
|
||||
it('getNodeIdentity triggers eager migration and does not expose legacy privateKey', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_abcd1234abcd1234');
|
||||
localStorage.setItem('sb_mesh_privkey', '{"fake":"jwk"}');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
|
||||
const identity = mod.getNodeIdentity();
|
||||
expect(identity).not.toBeNull();
|
||||
expect(identity!.privateKey).toBe('');
|
||||
|
||||
// The eager migration fires asynchronously (void ensureSigningPrivateKey()).
|
||||
// In this test environment crypto.subtle.importKey will fail on the fake JWK,
|
||||
// but the extractable browser copy should still be scrubbed.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(localStorage.getItem('sb_mesh_privkey')).toBeNull();
|
||||
expect(identity!.publicKey).toBe('test-pub');
|
||||
});
|
||||
|
||||
it('purgeBrowserSigningMaterial clears IndexedDB signing key', async () => {
|
||||
const { deleteKey } = await import('@/mesh/meshKeyStore');
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
idbStore.set('sb_mesh_sign_priv', 'mock-crypto-key');
|
||||
localStorage.setItem('sb_mesh_privkey', '{"fake":"jwk"}');
|
||||
localStorage.setItem('sb_mesh_sequence', '42');
|
||||
|
||||
await mod.purgeBrowserSigningMaterial();
|
||||
|
||||
expect(deleteKey).toHaveBeenCalledWith('sb_mesh_sign_priv');
|
||||
expect(localStorage.getItem('sb_mesh_privkey')).toBeNull();
|
||||
expect(localStorage.getItem('sb_mesh_sequence')).toBeNull();
|
||||
});
|
||||
|
||||
it('clearBrowserIdentityState clears both DH and signing keys from IndexedDB', async () => {
|
||||
const { deleteKey } = await import('@/mesh/meshKeyStore');
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_test');
|
||||
localStorage.setItem('sb_mesh_privkey', '{"fake":"jwk"}');
|
||||
localStorage.setItem('sb_mesh_session_mode', 'true');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
localStorage.setItem('sb_dm_bundle_fingerprint', 'bundle-fp');
|
||||
sessionStorage.setItem('sb_wormhole_desc_node_id', '!sb_gate');
|
||||
sessionStorage.setItem('sb_mesh_dm_ratchet', 'encrypted');
|
||||
sessionStorage.setItem('sb_mesh_ratchet_telemetry', '{"seen":1}');
|
||||
|
||||
await mod.clearBrowserIdentityState();
|
||||
|
||||
expect(deleteKey).toHaveBeenCalledWith('sb_mesh_dh_priv');
|
||||
expect(deleteKey).toHaveBeenCalledWith('sb_mesh_sign_priv');
|
||||
expect(localStorage.getItem('sb_mesh_pubkey')).toBeNull();
|
||||
expect(localStorage.getItem('sb_mesh_privkey')).toBeNull();
|
||||
expect(localStorage.getItem('sb_dm_bundle_fingerprint')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_wormhole_desc_node_id')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_dm_ratchet')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_ratchet_telemetry')).toBeNull();
|
||||
expect(deletedDatabases).toContain('sb_mesh_ratchet_crypto');
|
||||
});
|
||||
|
||||
it('generateDHKeys fails closed when non-extractable DH key storage is unavailable', async () => {
|
||||
const { setKey } = await import('@/mesh/meshKeyStore');
|
||||
vi.mocked(setKey).mockRejectedValueOnce(new Error('idb unavailable'));
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
await expect(mod.generateDHKeys()).rejects.toThrow('IndexedDB required for DH key storage');
|
||||
expect(localStorage.getItem('sb_mesh_dh_privkey')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_dh_privkey')).toBeNull();
|
||||
});
|
||||
|
||||
it('signWithStoredKey is exported and throws when no key available', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
// No key in IndexedDB or localStorage
|
||||
await expect(mod.signWithStoredKey('test message')).rejects.toThrow(
|
||||
'No signing key available',
|
||||
);
|
||||
});
|
||||
|
||||
it('signEvent fails closed when only public identity metadata exists', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
sessionStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
sessionStorage.setItem('sb_mesh_node_id', '!sb_abcd1234abcd1234');
|
||||
sessionStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
sessionStorage.setItem('sb_mesh_algo', 'Ed25519');
|
||||
|
||||
await expect(
|
||||
mod.signEvent('message', '!sb_abcd1234abcd1234', 1, { message: 'hello' }),
|
||||
).rejects.toThrow('No signing key available');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getMeshTerminalWriteLockReason,
|
||||
isMeshTerminalWriteCommand,
|
||||
} from '@/lib/meshTerminalPolicy';
|
||||
|
||||
describe('mesh terminal policy', () => {
|
||||
it('blocks sensitive terminal writes while anonymous mode is active', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: true,
|
||||
anonymousMode: true,
|
||||
anonymousModeReady: true,
|
||||
});
|
||||
|
||||
expect(reason).toContain('Anonymous Infonet mode');
|
||||
expect(isMeshTerminalWriteCommand('dm', ['add', '!sb_test'])).toBe(true);
|
||||
expect(isMeshTerminalWriteCommand('mesh', ['send', 'hello'])).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks sensitive terminal writes until Wormhole secure mode is ready', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: false,
|
||||
anonymousMode: false,
|
||||
anonymousModeReady: false,
|
||||
});
|
||||
|
||||
expect(reason).toContain('until Wormhole secure mode is ready');
|
||||
expect(isMeshTerminalWriteCommand('gate', ['create', 'newsroom'])).toBe(true);
|
||||
expect(isMeshTerminalWriteCommand('send', ['broadcast', 'hello'])).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps read-only terminal commands available', () => {
|
||||
expect(isMeshTerminalWriteCommand('status', [])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('signals', ['10'])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('mesh', ['listen', '20'])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('messages', [])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
getSenderRecoveryState,
|
||||
REQUEST_V2_REDUCED_VERSION,
|
||||
recoverSenderSealWithFallback,
|
||||
requiresSenderRecovery,
|
||||
shouldAllowRequestActions,
|
||||
shouldKeepUnresolvedRequestVisible,
|
||||
shouldPromoteRecoveredSenderForBootstrap,
|
||||
shouldPromoteRecoveredSenderForKnownContact,
|
||||
} from '@/mesh/requestSenderRecovery';
|
||||
|
||||
describe('requestSenderRecovery', () => {
|
||||
it('only promotes a known-contact sender when the seal verified and the sender matches', () => {
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForKnownContact(
|
||||
{ sender_id: 'alice', seal_verified: true },
|
||||
'alice',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForKnownContact(
|
||||
{ sender_id: 'alice', seal_verified: false },
|
||||
'alice',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForKnownContact(
|
||||
{ sender_id: 'mallory', seal_verified: true },
|
||||
'alice',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('only promotes a bootstrap-recovered sender when the seal verified', () => {
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForBootstrap({
|
||||
sender_id: 'alice',
|
||||
seal_verified: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForBootstrap({
|
||||
sender_id: 'alice',
|
||||
seal_verified: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(shouldPromoteRecoveredSenderForBootstrap(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers explicit request-v2 recovery markers over sealed-string inference', () => {
|
||||
expect(
|
||||
requiresSenderRecovery({
|
||||
sender_id: 'opaque',
|
||||
sender_seal: 'v3:test',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSenderRecoveryState({
|
||||
sender_id: 'opaque',
|
||||
sender_seal: 'v3:test',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
}),
|
||||
).toBe('pending');
|
||||
expect(
|
||||
requiresSenderRecovery({
|
||||
sender_id: 'sealed:abcd',
|
||||
sender_seal: 'v2:test',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('only allows request actions once canonical recovery reaches verified', () => {
|
||||
expect(
|
||||
shouldAllowRequestActions({
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'verified',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAllowRequestActions({
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'pending',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAllowRequestActions({
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'failed',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAllowRequestActions({
|
||||
request_contract_version: undefined,
|
||||
sender_recovery_required: undefined,
|
||||
sender_recovery_state: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps only pending or failed canonical request-v2 mail visible in the unresolved inbox flow', () => {
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'request',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'pending',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'request',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'failed',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'request',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'verified',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'request',
|
||||
request_contract_version: undefined,
|
||||
sender_recovery_required: undefined,
|
||||
sender_recovery_state: 'pending',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'shared',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'pending',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers local recovery and only falls back to the helper on local failure', async () => {
|
||||
const openLocal = vi.fn().mockResolvedValue({
|
||||
sender_id: 'alice',
|
||||
seal_verified: true,
|
||||
});
|
||||
const openHelper = vi.fn().mockResolvedValue({
|
||||
sender_id: 'helper-alice',
|
||||
seal_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
recoverSenderSealWithFallback({
|
||||
wormholeReady: true,
|
||||
openLocal,
|
||||
openHelper,
|
||||
}),
|
||||
).resolves.toEqual({ sender_id: 'alice', seal_verified: true });
|
||||
|
||||
expect(openLocal).toHaveBeenCalledTimes(1);
|
||||
expect(openHelper).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the helper only as fallback when local recovery cannot open the seal', async () => {
|
||||
const openLocal = vi.fn().mockResolvedValue(null);
|
||||
const openHelper = vi.fn().mockResolvedValue({
|
||||
sender_id: 'alice',
|
||||
seal_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
recoverSenderSealWithFallback({
|
||||
wormholeReady: true,
|
||||
openLocal,
|
||||
openHelper,
|
||||
}),
|
||||
).resolves.toEqual({ sender_id: 'alice', seal_verified: true });
|
||||
|
||||
expect(openLocal).toHaveBeenCalledTimes(1);
|
||||
expect(openHelper).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not invoke the helper when Wormhole fallback is unavailable', async () => {
|
||||
const openLocal = vi.fn().mockResolvedValue(null);
|
||||
const openHelper = vi.fn().mockResolvedValue({
|
||||
sender_id: 'alice',
|
||||
seal_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
recoverSenderSealWithFallback({
|
||||
wormholeReady: false,
|
||||
openLocal,
|
||||
openHelper,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(openLocal).toHaveBeenCalledTimes(1);
|
||||
expect(openHelper).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats helper failure as unresolved instead of promoting helper authority', async () => {
|
||||
const openLocal = vi.fn().mockResolvedValue(null);
|
||||
const openHelper = vi.fn().mockRejectedValue(new Error('helper_failed'));
|
||||
|
||||
await expect(
|
||||
recoverSenderSealWithFallback({
|
||||
wormholeReady: true,
|
||||
openLocal,
|
||||
openHelper,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(openLocal).toHaveBeenCalledTimes(1);
|
||||
expect(openHelper).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
ensureCanonicalRequestV2SenderSeal,
|
||||
REQUEST_V2_SENDER_SEAL_VERSION_ERROR,
|
||||
requiresCanonicalRequestV2SenderSeal,
|
||||
} from '@/mesh/requestSenderSealPolicy';
|
||||
|
||||
describe('requestSenderSealPolicy', () => {
|
||||
it('requires canonical v3 seals only for request-class sealed sender', () => {
|
||||
expect(
|
||||
requiresCanonicalRequestV2SenderSeal({
|
||||
deliveryClass: 'request',
|
||||
useSealedSender: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresCanonicalRequestV2SenderSeal({
|
||||
deliveryClass: 'request',
|
||||
useSealedSender: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
requiresCanonicalRequestV2SenderSeal({
|
||||
deliveryClass: 'shared',
|
||||
useSealedSender: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts v3 seals and rejects non-v3 seals for canonical request-v2 sender sealing', () => {
|
||||
expect(ensureCanonicalRequestV2SenderSeal('v3:ephemeral:payload')).toBe(
|
||||
'v3:ephemeral:payload',
|
||||
);
|
||||
expect(() => ensureCanonicalRequestV2SenderSeal('v2:legacy-payload')).toThrow(
|
||||
REQUEST_V2_SENDER_SEAL_VERSION_ERROR,
|
||||
);
|
||||
expect(() => ensureCanonicalRequestV2SenderSeal('')).toThrow(
|
||||
REQUEST_V2_SENDER_SEAL_VERSION_ERROR,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const idbStore = new Map<string, unknown>();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn(async (id: string) => idbStore.get(id) ?? null),
|
||||
setKey: vi.fn(async (id: string, key: unknown) => {
|
||||
idbStore.set(id, key);
|
||||
}),
|
||||
deleteKey: vi.fn(async (id: string) => {
|
||||
idbStore.delete(id);
|
||||
}),
|
||||
}));
|
||||
|
||||
function bufToBase64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
async function buildV3SealForRecipient(params: {
|
||||
recipientPublicKey: CryptoKey;
|
||||
recipientId: string;
|
||||
msgId: string;
|
||||
plaintext: string;
|
||||
}) {
|
||||
const { encryptDM } = await import('@/mesh/meshIdentity');
|
||||
const { PROTOCOL_VERSION } = await import('@/mesh/meshProtocol');
|
||||
const ephemeral = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
const ephemeralPubRaw = await crypto.subtle.exportKey('raw', ephemeral.publicKey);
|
||||
const ephemeralPub = bufToBase64(ephemeralPubRaw);
|
||||
const secret = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: params.recipientPublicKey },
|
||||
ephemeral.privateKey,
|
||||
256,
|
||||
);
|
||||
const salt = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(
|
||||
`SB-SEAL-SALT|${params.recipientId}|${params.msgId}|${PROTOCOL_VERSION}|${ephemeralPub}`,
|
||||
),
|
||||
);
|
||||
const hkdfKey = await crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveKey']);
|
||||
const sealKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt,
|
||||
info: new TextEncoder().encode('SB-SENDER-SEAL-V3'),
|
||||
},
|
||||
hkdfKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
const ciphertext = await encryptDM(params.plaintext, sealKey);
|
||||
return `v3:${ephemeralPub}:${ciphertext}`;
|
||||
}
|
||||
|
||||
describe('request sender seal recovery window', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
idbStore.clear();
|
||||
const makeStorage = () => {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
};
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a v3 sender seal with the immediately previous retained recipient key', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const previousRecipient = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
const currentRecipient = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
|
||||
idbStore.set('sb_mesh_dh_priv', currentRecipient.privateKey);
|
||||
idbStore.set('sb_mesh_dh_prev_priv', previousRecipient.privateKey);
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
|
||||
const plaintext = JSON.stringify({ sender_id: 'alice', msg_id: 'msg-rotation' });
|
||||
const senderSeal = await buildV3SealForRecipient({
|
||||
recipientPublicKey: previousRecipient.publicKey,
|
||||
recipientId: '!sb_recipient',
|
||||
msgId: 'msg-rotation',
|
||||
plaintext,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mod.decryptSenderSealPayloadLocally(senderSeal, '', '!sb_recipient', 'msg-rotation'),
|
||||
).resolves.toBe(plaintext);
|
||||
});
|
||||
|
||||
it('returns null when the prior retained recipient key is unavailable', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const previousRecipient = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
const currentRecipient = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
|
||||
idbStore.set('sb_mesh_dh_priv', currentRecipient.privateKey);
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
|
||||
const senderSeal = await buildV3SealForRecipient({
|
||||
recipientPublicKey: previousRecipient.publicKey,
|
||||
recipientId: '!sb_recipient',
|
||||
msgId: 'msg-rotation-miss',
|
||||
plaintext: JSON.stringify({ sender_id: 'alice', msg_id: 'msg-rotation-miss' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
mod.decryptSenderSealPayloadLocally(senderSeal, '', '!sb_recipient', 'msg-rotation-miss'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('retains the current DH private key in the previous-key slot when rotating', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const existing = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(crypto.subtle);
|
||||
const generateKeySpy = vi
|
||||
.spyOn(crypto.subtle, 'generateKey')
|
||||
.mockImplementation(((algorithm: AlgorithmIdentifier, extractable: boolean, keyUsages: KeyUsage[]) => {
|
||||
if (algorithm === 'X25519') {
|
||||
return Promise.reject(new Error('x25519_unavailable_for_test'));
|
||||
}
|
||||
return originalGenerateKey(algorithm, extractable, keyUsages);
|
||||
}) as typeof crypto.subtle.generateKey);
|
||||
|
||||
idbStore.set('sb_mesh_dh_priv', existing.privateKey);
|
||||
try {
|
||||
await mod.generateDHKeys();
|
||||
|
||||
expect(idbStore.get('sb_mesh_dh_prev_priv')).toBe(existing.privateKey);
|
||||
expect(idbStore.get('sb_mesh_dh_priv')).not.toBe(existing.privateKey);
|
||||
} finally {
|
||||
generateKeySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const getNodeIdentity = vi.fn<
|
||||
() => { nodeId: string; publicKey: string; privateKey: string } | null
|
||||
>(() => null);
|
||||
const signEvent = vi.fn();
|
||||
const signMessage = vi.fn();
|
||||
const signWithStoredKey = vi.fn();
|
||||
const isSecureModeCached = vi.fn(() => true);
|
||||
const fetchWormholeSettings = vi.fn(async () => ({ enabled: true }));
|
||||
const fetchWormholeState = vi.fn(async () => ({ ready: true }));
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
cacheWormholeIdentityDescriptor: vi.fn(),
|
||||
getNodeIdentity,
|
||||
getPublicKeyAlgo: vi.fn(() => 'ed25519'),
|
||||
isSecureModeCached,
|
||||
purgeBrowserSigningMaterial: vi.fn(async () => {}),
|
||||
setSecureModeCached: vi.fn(),
|
||||
signEvent,
|
||||
signMessage,
|
||||
signWithStoredKey,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshProtocol', () => ({
|
||||
PROTOCOL_VERSION: 'sb-test',
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeClient', () => ({
|
||||
fetchWormholeSettings,
|
||||
fetchWormholeState,
|
||||
}));
|
||||
|
||||
describe('wormholeIdentityClient strict profile hints', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
controlPlaneJson.mockResolvedValue({ ok: true });
|
||||
getNodeIdentity.mockReset();
|
||||
getNodeIdentity.mockReturnValue(null);
|
||||
signEvent.mockReset();
|
||||
signMessage.mockReset();
|
||||
signWithStoredKey.mockReset();
|
||||
isSecureModeCached.mockReset();
|
||||
isSecureModeCached.mockReturnValue(true);
|
||||
fetchWormholeSettings.mockReset();
|
||||
fetchWormholeSettings.mockResolvedValue({ enabled: true });
|
||||
fetchWormholeState.mockReset();
|
||||
fetchWormholeState.mockResolvedValue({ ready: true });
|
||||
});
|
||||
|
||||
it('applies strict gate_operator enforcement to gate persona and compose operations', async () => {
|
||||
const mod = await import('@/mesh/wormholeIdentityClient');
|
||||
|
||||
await mod.listWormholeGatePersonas('infonet');
|
||||
await mod.createWormholeGatePersona('infonet', 'persona-1');
|
||||
await mod.activateWormholeGatePersona('infonet', 'persona-1');
|
||||
await mod.clearWormholeGatePersona('infonet');
|
||||
await mod.retireWormholeGatePersona('infonet', 'persona-1');
|
||||
await mod.composeWormholeGateMessage('infonet', 'hello');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/wormhole/gate/infonet/personas',
|
||||
expect.objectContaining({
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
}),
|
||||
);
|
||||
for (let i = 2; i <= 5; i += 1) {
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
i,
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
'/api/wormhole/gate/message/compose',
|
||||
expect.objectContaining({
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('browser raw signing fails closed instead of falling back to legacy jwk signing', async () => {
|
||||
fetchWormholeSettings.mockResolvedValue({ enabled: false });
|
||||
fetchWormholeState.mockResolvedValue({ ready: false });
|
||||
getNodeIdentity.mockReturnValue({
|
||||
nodeId: '!sb_browser',
|
||||
publicKey: 'browser-pub',
|
||||
privateKey: '',
|
||||
});
|
||||
signWithStoredKey.mockRejectedValue(new Error('no key'));
|
||||
|
||||
const mod = await import('@/mesh/wormholeIdentityClient');
|
||||
|
||||
await expect(mod.signRawMeshMessage('payload')).rejects.toThrow(
|
||||
'browser_signing_key_unavailable',
|
||||
);
|
||||
expect(signWithStoredKey).toHaveBeenCalledWith('payload');
|
||||
expect(signMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the cached secure boundary when wormhole settings fetch fails', async () => {
|
||||
fetchWormholeSettings.mockRejectedValue(new Error('network down'));
|
||||
isSecureModeCached.mockReturnValue(true);
|
||||
|
||||
const mod = await import('@/mesh/wormholeIdentityClient');
|
||||
|
||||
await expect(mod.isWormholeSecureRequired()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyAircraft, HELI_TYPES, TURBOPROP_TYPES, BIZJET_TYPES } from '@/utils/aircraftClassification';
|
||||
import {
|
||||
classifyAircraft,
|
||||
HELI_TYPES,
|
||||
TURBOPROP_TYPES,
|
||||
BIZJET_TYPES,
|
||||
} from '@/utils/aircraftClassification';
|
||||
|
||||
describe('classifyAircraft', () => {
|
||||
// ─── Helicopter classification ────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const idbStore = new Map<string, unknown>();
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn(async (id: string) => idbStore.get(id) ?? null),
|
||||
setKey: vi.fn(async (id: string, key: unknown) => {
|
||||
idbStore.set(id, key);
|
||||
}),
|
||||
deleteKey: vi.fn(async (id: string) => {
|
||||
idbStore.delete(id);
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
function bufToBase64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
async function provisionLocalIdentity(): Promise<void> {
|
||||
const meshIdentity = await import('@/mesh/meshIdentity');
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_sensitive123456');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
const keyPair = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveKey', 'deriveBits'],
|
||||
)) as CryptoKeyPair;
|
||||
const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
localStorage.setItem('sb_mesh_dh_pubkey', bufToBase64(publicRaw));
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
idbStore.set('sb_mesh_dh_priv', keyPair.privateKey);
|
||||
meshIdentity.getNodeIdentity();
|
||||
}
|
||||
|
||||
describe('identityBoundSensitiveStorage', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
idbStore.clear();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores encrypted values in sensitive storage and keeps them out of localStorage', async () => {
|
||||
await provisionLocalIdentity();
|
||||
const storage = await import('@/lib/identityBoundSensitiveStorage');
|
||||
|
||||
await storage.persistIdentityBoundSensitiveValue(
|
||||
'sb_access_requests:test',
|
||||
'SB-ACCESS-REQUESTS-STORAGE-V1',
|
||||
[{ sender_id: 'alice', ts: 1 }],
|
||||
);
|
||||
|
||||
expect(String(sessionStorage.getItem('sb_access_requests:test') ?? '')).toMatch(/^enc:/);
|
||||
expect(localStorage.getItem('sb_access_requests:test')).toBeNull();
|
||||
|
||||
const hydrated = await storage.loadIdentityBoundSensitiveValue(
|
||||
'sb_access_requests:test',
|
||||
'SB-ACCESS-REQUESTS-STORAGE-V1',
|
||||
[],
|
||||
);
|
||||
expect(hydrated).toEqual([{ sender_id: 'alice', ts: 1 }]);
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext sensitive values into encrypted session-backed storage', async () => {
|
||||
await provisionLocalIdentity();
|
||||
const storage = await import('@/lib/identityBoundSensitiveStorage');
|
||||
|
||||
localStorage.setItem('sb_mesh_muted', JSON.stringify(['alice', 'bob']));
|
||||
|
||||
const hydrated = await storage.loadIdentityBoundSensitiveValue(
|
||||
'sb_mesh_muted:!sb_sensitive123456',
|
||||
'SB-MUTED-LIST-V1',
|
||||
[],
|
||||
{ legacyKey: 'sb_mesh_muted' },
|
||||
);
|
||||
|
||||
expect(hydrated).toEqual(['alice', 'bob']);
|
||||
expect(String(sessionStorage.getItem('sb_mesh_muted:!sb_sensitive123456') ?? '')).toMatch(/^enc:/);
|
||||
expect(localStorage.getItem('sb_mesh_muted')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_muted')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function makeStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('privacyBrowserStorage', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores sensitive items in sessionStorage by default', async () => {
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
mod.setSensitiveBrowserItem('secret-key', 'alpha');
|
||||
|
||||
expect(mod.getSensitiveBrowserStorageMode()).toBe('session');
|
||||
expect(sessionStorage.getItem('secret-key')).toBe('alpha');
|
||||
expect(localStorage.getItem('secret-key')).toBeNull();
|
||||
expect(mod.getSensitiveBrowserItem('secret-key')).toBe('alpha');
|
||||
});
|
||||
|
||||
it('stores privacy preferences in session storage when session mode is enabled', async () => {
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
mod.setSessionModePreference(true);
|
||||
mod.setPrivacyStrictPreference(true, { sessionMode: true });
|
||||
mod.setPrivacyProfilePreference('high', { sessionMode: true });
|
||||
|
||||
expect(mod.getSessionModePreference()).toBe(true);
|
||||
expect(mod.getPrivacyStrictPreference()).toBe(true);
|
||||
expect(mod.getPrivacyProfilePreference()).toBe('high');
|
||||
expect(sessionStorage.getItem('sb_mesh_session_mode')).toBe('true');
|
||||
expect(sessionStorage.getItem('sb_privacy_strict')).toBe('true');
|
||||
expect(sessionStorage.getItem('sb_privacy_profile')).toBe('high');
|
||||
expect(localStorage.getItem('sb_mesh_session_mode')).toBeNull();
|
||||
expect(localStorage.getItem('sb_privacy_strict')).toBeNull();
|
||||
expect(localStorage.getItem('sb_privacy_profile')).toBeNull();
|
||||
});
|
||||
|
||||
it('persists session mode locally only when the user explicitly disables it', async () => {
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
mod.setSessionModePreference(false);
|
||||
|
||||
expect(mod.getSessionModePreference()).toBe(false);
|
||||
expect(localStorage.getItem('sb_mesh_session_mode')).toBe('false');
|
||||
expect(sessionStorage.getItem('sb_mesh_session_mode')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores sensitive items in sessionStorage when privacy strict is enabled', async () => {
|
||||
localStorage.setItem('sb_privacy_strict', 'true');
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
mod.setSensitiveBrowserItem('secret-key', 'bravo');
|
||||
|
||||
expect(mod.getSensitiveBrowserStorageMode()).toBe('session');
|
||||
expect(sessionStorage.getItem('secret-key')).toBe('bravo');
|
||||
expect(localStorage.getItem('secret-key')).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates legacy localStorage values into sessionStorage in strict mode', async () => {
|
||||
localStorage.setItem('sb_privacy_strict', 'true');
|
||||
localStorage.setItem('secret-key', 'charlie');
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
expect(mod.getSensitiveBrowserItem('secret-key')).toBe('charlie');
|
||||
expect(sessionStorage.getItem('secret-key')).toBe('charlie');
|
||||
expect(localStorage.getItem('secret-key')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,8 @@ describe('computeNightPolygon', () => {
|
||||
.filter(([lng]: number[]) => lng >= -180 && lng <= 180)
|
||||
.slice(0, 361)
|
||||
.map(([, lat]: number[]) => lat);
|
||||
const avgLat = terminatorLats.reduce((a: number, b: number) => a + b, 0) / terminatorLats.length;
|
||||
const avgLat =
|
||||
terminatorLats.reduce((a: number, b: number) => a + b, 0) / terminatorLats.length;
|
||||
expect(avgLat).toBeLessThan(15);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBoundsQuery,
|
||||
coarsenViewBounds,
|
||||
expandBoundsToRadius,
|
||||
} from '@/lib/viewportPrivacy';
|
||||
|
||||
describe('viewport privacy helper', () => {
|
||||
it('coarsens narrow bounds outward without clipping the original view', () => {
|
||||
const original = {
|
||||
south: 33.612,
|
||||
west: -84.452,
|
||||
north: 33.781,
|
||||
east: -84.211,
|
||||
};
|
||||
|
||||
const coarse = coarsenViewBounds(original);
|
||||
|
||||
expect(coarse.south).toBeLessThanOrEqual(original.south);
|
||||
expect(coarse.west).toBeLessThanOrEqual(original.west);
|
||||
expect(coarse.north).toBeGreaterThanOrEqual(original.north);
|
||||
expect(coarse.east).toBeGreaterThanOrEqual(original.east);
|
||||
expect(coarse.south).toBe(33.6);
|
||||
expect(coarse.west).toBe(-84.5);
|
||||
expect(coarse.north).toBe(33.8);
|
||||
expect(coarse.east).toBe(-84.2);
|
||||
});
|
||||
|
||||
it('canonicalizes the bounds query so nearby pans in the same coarse cell dedupe', () => {
|
||||
const a = buildBoundsQuery({
|
||||
south: 47.6011,
|
||||
west: -122.3484,
|
||||
north: 47.6902,
|
||||
east: -122.2012,
|
||||
});
|
||||
const b = buildBoundsQuery({
|
||||
south: 47.6039,
|
||||
west: -122.3441,
|
||||
north: 47.6883,
|
||||
east: -122.2051,
|
||||
});
|
||||
|
||||
expect(a).toBe('?s=47.60&w=-122.35&n=47.70&e=-122.20');
|
||||
expect(b).toBe(a);
|
||||
});
|
||||
|
||||
it('expands bounds to a fixed preload radius around the current view center', () => {
|
||||
const original = {
|
||||
south: 39.55,
|
||||
west: -105.25,
|
||||
north: 39.95,
|
||||
east: -104.75,
|
||||
};
|
||||
|
||||
const expanded = expandBoundsToRadius(original, 3000);
|
||||
|
||||
expect(expanded.south).toBeLessThanOrEqual(original.south);
|
||||
expect(expanded.west).toBeLessThanOrEqual(original.west);
|
||||
expect(expanded.north).toBeGreaterThanOrEqual(original.north);
|
||||
expect(expanded.east).toBeGreaterThanOrEqual(original.east);
|
||||
expect(expanded.north - expanded.south).toBeGreaterThan(80);
|
||||
expect(expanded.east - expanded.west).toBeGreaterThan(90);
|
||||
});
|
||||
});
|
||||
@@ -5,16 +5,27 @@
|
||||
* the client bundle or the build manifest.
|
||||
*
|
||||
* Set BACKEND_URL in docker-compose `environment:` (e.g. http://backend:8000)
|
||||
* to use Docker internal networking. Defaults to http://localhost:8000 for
|
||||
* to use Docker internal networking. Defaults to http://127.0.0.1:8000 for
|
||||
* local development where both services run on the same host.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { resolveAdminSessionToken } from '@/lib/server/adminSessionStore';
|
||||
|
||||
// Headers that must not be forwarded to the backend.
|
||||
const STRIP_REQUEST = new Set([
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade", "host",
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'x-admin-key',
|
||||
'te',
|
||||
'trailers',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'host',
|
||||
]);
|
||||
|
||||
// Headers that must not be forwarded back to the browser.
|
||||
@@ -22,60 +33,222 @@ const STRIP_REQUEST = new Set([
|
||||
// automatically decompresses gzip/br responses — forwarding these headers
|
||||
// would cause ERR_CONTENT_DECODING_FAILED in the browser.
|
||||
const STRIP_RESPONSE = new Set([
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade",
|
||||
"content-encoding", "content-length",
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailers',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
]);
|
||||
|
||||
async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
|
||||
const targetUrl = new URL(`/api/${path.join("/")}`, backendUrl);
|
||||
targetUrl.search = req.nextUrl.search;
|
||||
const ADMIN_COOKIE = 'sb_admin_session';
|
||||
const NO_STORE_PROXY_HEADERS = {
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
|
||||
// Forward relevant request headers
|
||||
const forwardHeaders = new Headers();
|
||||
req.headers.forEach((value, key) => {
|
||||
if (!STRIP_REQUEST.has(key.toLowerCase())) {
|
||||
forwardHeaders.set(key, value);
|
||||
}
|
||||
});
|
||||
function isSensitiveProxyPath(pathSegments: string[]): boolean {
|
||||
const joined = pathSegments.join('/');
|
||||
if (!joined) return false;
|
||||
if (pathSegments[0] === 'wormhole') return true;
|
||||
if (joined === 'refresh') return true;
|
||||
if (joined === 'debug-latest') return true;
|
||||
if (joined === 'system/update') return true;
|
||||
if (pathSegments[0] === 'settings') return true;
|
||||
if (joined === 'mesh/infonet/ingest') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const isBodyless = req.method === "GET" || req.method === "HEAD";
|
||||
let upstream: Response;
|
||||
async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResponse> {
|
||||
try {
|
||||
upstream = await fetch(targetUrl.toString(), {
|
||||
const isMesh = pathSegments[0] === 'mesh';
|
||||
const meshSegments = pathSegments.slice(1);
|
||||
const isSensitiveMeshPath = isMesh && meshSegments[0] === 'dm';
|
||||
const isAnonymousMeshWritePath =
|
||||
isMesh &&
|
||||
!isSensitiveMeshPath &&
|
||||
['POST', 'PUT', 'DELETE'].includes(req.method.toUpperCase()) &&
|
||||
(meshSegments.join('/') === 'send' ||
|
||||
meshSegments.join('/') === 'vote' ||
|
||||
meshSegments.join('/') === 'report' ||
|
||||
meshSegments.join('/') === 'gate/create' ||
|
||||
(meshSegments[0] === 'gate' && meshSegments[2] === 'message') ||
|
||||
meshSegments.join('/') === 'oracle/predict' ||
|
||||
meshSegments.join('/') === 'oracle/resolve' ||
|
||||
meshSegments.join('/') === 'oracle/stake' ||
|
||||
meshSegments.join('/') === 'oracle/resolve-stakes');
|
||||
const backendUrl = process.env.BACKEND_URL ?? 'http://127.0.0.1:8000';
|
||||
let targetBase = backendUrl;
|
||||
|
||||
if (isMesh) {
|
||||
const envEnabled = (process.env.WORMHOLE_ENABLED || '').toLowerCase();
|
||||
let wormholeEnabled = ['1', 'true', 'yes'].includes(envEnabled);
|
||||
let privacyProfile = (process.env.WORMHOLE_PRIVACY_PROFILE || '').toLowerCase();
|
||||
let anonymousMode = ['1', 'true', 'yes'].includes(
|
||||
(process.env.WORMHOLE_ANONYMOUS_MODE || '').toLowerCase(),
|
||||
);
|
||||
let wormholeReady = false;
|
||||
let effectiveTransport = '';
|
||||
|
||||
if (!wormholeEnabled || !privacyProfile || !anonymousMode) {
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
const repoRoot = cwd.endsWith(path.sep + 'frontend') ? path.resolve(cwd, '..') : cwd;
|
||||
const wormholeFile = path.join(repoRoot, 'backend', 'data', 'wormhole.json');
|
||||
if (fs.existsSync(wormholeFile)) {
|
||||
const raw = fs.readFileSync(wormholeFile, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
if (!wormholeEnabled) {
|
||||
wormholeEnabled = Boolean(data && data.enabled);
|
||||
}
|
||||
privacyProfile = privacyProfile || String(data?.privacy_profile || '').toLowerCase();
|
||||
if (!anonymousMode) {
|
||||
anonymousMode = Boolean(data?.anonymous_mode);
|
||||
}
|
||||
}
|
||||
const wormholeStatusFile = path.join(repoRoot, 'backend', 'data', 'wormhole_status.json');
|
||||
if (fs.existsSync(wormholeStatusFile)) {
|
||||
const raw = fs.readFileSync(wormholeStatusFile, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
wormholeReady = Boolean(data?.running) && Boolean(data?.ready);
|
||||
effectiveTransport = String(data?.transport_active || data?.transport || '').toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
wormholeEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (privacyProfile === 'high' && !wormholeEnabled && isSensitiveMeshPath) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
detail: 'High privacy requires Wormhole. Enable it in Settings and restart.',
|
||||
}),
|
||||
{ status: 428, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
if (wormholeEnabled && isSensitiveMeshPath) {
|
||||
if (!wormholeReady) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
detail: 'Wormhole is enabled but not connected yet. Start Wormhole to use secure DM features.',
|
||||
}),
|
||||
{ status: 503, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
targetBase = process.env.WORMHOLE_URL ?? 'http://127.0.0.1:8787';
|
||||
}
|
||||
|
||||
if (anonymousMode && isAnonymousMeshWritePath) {
|
||||
if (!wormholeEnabled) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
detail: 'Anonymous mode requires Wormhole to be enabled before public posting.',
|
||||
}),
|
||||
{ status: 428, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
const hiddenReady = wormholeReady && ['tor', 'i2p', 'mixnet'].includes(effectiveTransport);
|
||||
if (!hiddenReady) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
detail: 'Anonymous mode requires Wormhole hidden transport (Tor/I2P/Mixnet) to be ready.',
|
||||
}),
|
||||
{ status: 428, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
targetBase = process.env.WORMHOLE_URL ?? 'http://127.0.0.1:8787';
|
||||
}
|
||||
}
|
||||
|
||||
const targetUrl = new URL(`/api/${pathSegments.join('/')}`, targetBase);
|
||||
targetUrl.search = req.nextUrl.search;
|
||||
|
||||
const forwardHeaders = new Headers();
|
||||
req.headers.forEach((value, key) => {
|
||||
if (!STRIP_REQUEST.has(key.toLowerCase())) {
|
||||
forwardHeaders.set(key, value);
|
||||
}
|
||||
});
|
||||
if (isSensitiveProxyPath(pathSegments)) {
|
||||
const cookieToken = req.cookies.get(ADMIN_COOKIE)?.value || '';
|
||||
const injectedAdmin = process.env.ADMIN_KEY || resolveAdminSessionToken(cookieToken) || '';
|
||||
if (injectedAdmin) {
|
||||
forwardHeaders.set('X-Admin-Key', injectedAdmin);
|
||||
}
|
||||
}
|
||||
|
||||
const isBodyless = req.method === 'GET' || req.method === 'HEAD';
|
||||
let upstream: Response;
|
||||
const requestInit: RequestInit & { duplex?: 'half' } = {
|
||||
method: req.method,
|
||||
headers: forwardHeaders,
|
||||
body: isBodyless ? undefined : req.body,
|
||||
cache: 'no-store',
|
||||
};
|
||||
if (!isBodyless) {
|
||||
requestInit.body = req.body;
|
||||
// Required for streaming request bodies in Node.js fetch
|
||||
// @ts-ignore
|
||||
duplex: "half",
|
||||
});
|
||||
} catch (err) {
|
||||
// Backend unreachable — return a clean 502 so the UI can handle it gracefully
|
||||
return new NextResponse(JSON.stringify({ error: "Backend unavailable" }), {
|
||||
status: 502,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Forward response headers
|
||||
const responseHeaders = new Headers();
|
||||
upstream.headers.forEach((value, key) => {
|
||||
if (!STRIP_RESPONSE.has(key.toLowerCase())) {
|
||||
responseHeaders.set(key, value);
|
||||
requestInit.duplex = 'half';
|
||||
}
|
||||
try {
|
||||
upstream = await fetch(targetUrl.toString(), requestInit);
|
||||
} catch {
|
||||
return new NextResponse(JSON.stringify({ error: 'Backend unavailable' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 304 responses must have no body
|
||||
if (upstream.status === 304) {
|
||||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||
const responseHeaders = new Headers();
|
||||
upstream.headers.forEach((value, key) => {
|
||||
if (!STRIP_RESPONSE.has(key.toLowerCase())) {
|
||||
responseHeaders.set(key, value);
|
||||
}
|
||||
});
|
||||
if (isSensitiveProxyPath(pathSegments) || isSensitiveMeshPath) {
|
||||
Object.entries(NO_STORE_PROXY_HEADERS).forEach(([key, value]) => {
|
||||
responseHeaders.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
if (upstream.status === 304) {
|
||||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||
}
|
||||
|
||||
// Stream the upstream body directly instead of buffering the full response.
|
||||
// This reduces TTFB and memory pressure for large payloads (flights, ships).
|
||||
return new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('api proxy unexpected error', {
|
||||
pathSegments,
|
||||
method: req.method,
|
||||
error,
|
||||
});
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: 'Proxy failed',
|
||||
detail: error instanceof Error ? error.message : 'unknown_error',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...NO_STORE_PROXY_HEADERS,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
@@ -90,6 +263,9 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ path
|
||||
return proxy(req, (await params).path);
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
return proxy(req, (await params).path);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
clearAdminSessionToken,
|
||||
createAdminSessionToken,
|
||||
hasAdminSessionToken,
|
||||
} from '@/lib/server/adminSessionStore';
|
||||
|
||||
const COOKIE_NAME = 'sb_admin_session';
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 8;
|
||||
const NO_STORE_HEADERS = {
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
|
||||
function cookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyAdminKey(adminKey: string): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||
const backendUrl = process.env.BACKEND_URL ?? 'http://127.0.0.1:8000';
|
||||
const verifyAgainstBackend = async (): Promise<
|
||||
{ ok: true } | { ok: false; detail: string }
|
||||
> => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/settings/privacy-profile`, {
|
||||
method: 'GET',
|
||||
headers: { 'X-Admin-Key': adminKey },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return {
|
||||
ok: false,
|
||||
detail: String(data?.detail || data?.message || 'Unable to verify admin key'),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
detail: 'Unable to verify admin key against backend',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const configuredAdmin = String(process.env.ADMIN_KEY || '').trim();
|
||||
if (configuredAdmin) {
|
||||
if (adminKey !== configuredAdmin) {
|
||||
return { ok: false, detail: 'Invalid admin key' };
|
||||
}
|
||||
return verifyAgainstBackend();
|
||||
}
|
||||
|
||||
return verifyAgainstBackend();
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const adminKey = String(body?.adminKey || '').trim();
|
||||
if (!adminKey) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, detail: 'Missing admin key' },
|
||||
{ status: 400, headers: NO_STORE_HEADERS },
|
||||
);
|
||||
}
|
||||
const verification = await verifyAdminKey(adminKey);
|
||||
if (!verification.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, detail: verification.detail },
|
||||
{ status: 403, headers: NO_STORE_HEADERS },
|
||||
);
|
||||
}
|
||||
const existingToken = req.cookies.get(COOKIE_NAME)?.value || '';
|
||||
if (existingToken) {
|
||||
clearAdminSessionToken(existingToken);
|
||||
}
|
||||
const sessionToken = createAdminSessionToken(adminKey, COOKIE_MAX_AGE);
|
||||
const res = NextResponse.json({ ok: true }, { headers: NO_STORE_HEADERS });
|
||||
res.cookies.set(COOKIE_NAME, sessionToken, cookieOptions());
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const existingToken = req.cookies.get(COOKIE_NAME)?.value || '';
|
||||
if (existingToken) {
|
||||
clearAdminSessionToken(existingToken);
|
||||
}
|
||||
const res = NextResponse.json({ ok: true }, { headers: NO_STORE_HEADERS });
|
||||
res.cookies.set(COOKIE_NAME, '', {
|
||||
...cookieOptions(),
|
||||
maxAge: 0,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.cookies.get(COOKIE_NAME)?.value || '';
|
||||
return NextResponse.json(
|
||||
{ ok: true, hasSession: hasAdminSessionToken(token) },
|
||||
{ headers: NO_STORE_HEADERS },
|
||||
);
|
||||
}
|
||||
+323
-66
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--background: #000000;
|
||||
@@ -7,47 +7,75 @@
|
||||
--bg-secondary: rgb(5, 5, 8);
|
||||
--bg-tertiary: rgb(12, 12, 16);
|
||||
--bg-panel: rgba(0, 0, 0, 0.85);
|
||||
--border-primary: rgb(10, 12, 15);
|
||||
--border-secondary: rgb(20, 24, 28);
|
||||
--border-primary: rgba(8, 145, 178, 0.18);
|
||||
--border-secondary: rgba(8, 145, 178, 0.30);
|
||||
--border-glow: rgba(6, 182, 212, 0.12);
|
||||
--text-primary: rgb(243, 244, 246);
|
||||
--text-secondary: rgb(34, 211, 238);
|
||||
--text-muted: rgb(8, 145, 178);
|
||||
--text-heading: rgb(236, 254, 255);
|
||||
--hover-accent: rgba(8, 51, 68, 0.2);
|
||||
--scrollbar-thumb: rgba(8, 145, 178, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(8, 145, 178, 0.5);
|
||||
--scrollbar-thumb: rgba(255, 255, 255, 0.12);
|
||||
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.25);
|
||||
--font-geist-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, 'Noto Sans',
|
||||
sans-serif;
|
||||
--font-roboto-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Light theme: only the map basemap changes — UI stays dark */
|
||||
[data-theme="light"] {
|
||||
[data-theme='light'] {
|
||||
--background: #000000;
|
||||
--foreground: #ededed;
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: rgb(5, 5, 8);
|
||||
--bg-tertiary: rgb(12, 12, 16);
|
||||
--bg-panel: rgba(0, 0, 0, 0.85);
|
||||
--border-primary: rgb(10, 12, 15);
|
||||
--border-secondary: rgb(20, 24, 28);
|
||||
--border-primary: rgba(8, 145, 178, 0.18);
|
||||
--border-secondary: rgba(8, 145, 178, 0.30);
|
||||
--border-glow: rgba(6, 182, 212, 0.12);
|
||||
--text-primary: rgb(243, 244, 246);
|
||||
--text-secondary: rgb(34, 211, 238);
|
||||
--text-muted: rgb(8, 145, 178);
|
||||
--text-heading: rgb(236, 254, 255);
|
||||
--hover-accent: rgba(8, 51, 68, 0.2);
|
||||
--scrollbar-thumb: rgba(8, 145, 178, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(8, 145, 178, 0.5);
|
||||
--scrollbar-thumb: rgba(255, 255, 255, 0.12);
|
||||
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-mono: var(--font-roboto-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-roboto-mono), 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
/* Global interactive cursor hints */
|
||||
button:not(:disabled),
|
||||
[role='button']:not([aria-disabled='true']),
|
||||
a[href],
|
||||
summary,
|
||||
label[for],
|
||||
input[type='button']:not(:disabled),
|
||||
input[type='submit']:not(:disabled),
|
||||
input[type='reset']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[role='button'][aria-disabled='true'],
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Styled thin scrollbar for dark HUD UI */
|
||||
@@ -70,15 +98,49 @@ body {
|
||||
|
||||
.styled-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
/* DOS block cursor blink */
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── TERMINAL UTILITY CLASSES ── */
|
||||
|
||||
/* Subtle text glow for cyan headings */
|
||||
.text-glow {
|
||||
text-shadow: 0 0 8px rgba(34, 211, 238, 0.3);
|
||||
}
|
||||
|
||||
/* Terminal input — prompt style */
|
||||
.terminal-input {
|
||||
border-radius: 0;
|
||||
border: 1px solid rgba(8, 145, 178, 0.25);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.terminal-input:focus {
|
||||
border-color: rgba(34, 211, 238, 0.5);
|
||||
box-shadow: 0 0 6px rgba(34, 211, 238, 0.15);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Map popup shared utilities */
|
||||
.map-popup {
|
||||
background: rgba(10, 14, 26, 0.95);
|
||||
border-radius: 6px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(8, 145, 178, 0.25);
|
||||
padding: 10px 14px;
|
||||
color: #e0e6f0;
|
||||
font-family: monospace;
|
||||
font-family:
|
||||
var(--font-roboto-mono), 'Roboto Mono', monospace, 'Microsoft YaHei', 'PingFang SC',
|
||||
'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', sans-serif;
|
||||
font-size: 11px;
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
@@ -116,77 +178,181 @@ body {
|
||||
|
||||
/* ── MATRIX HUD COLOR THEME ── */
|
||||
/* Remaps cyan accents → green within .hud-zone containers only */
|
||||
[data-hud="matrix"] .hud-zone {
|
||||
[data-hud='matrix'] .hud-zone {
|
||||
--text-secondary: #4ade80;
|
||||
--text-muted: #16a34a;
|
||||
--text-heading: #bbf7d0;
|
||||
--hover-accent: rgba(5, 46, 22, 0.2);
|
||||
--scrollbar-thumb: rgba(22, 163, 74, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(22, 163, 74, 0.5);
|
||||
--scrollbar-thumb: rgba(255, 255, 255, 0.12);
|
||||
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.25);
|
||||
--border-primary: rgba(22, 163, 74, 0.18);
|
||||
--border-secondary: rgba(22, 163, 74, 0.30);
|
||||
--border-glow: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .text-glow {
|
||||
text-shadow: 0 0 8px rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
/* --- Text color overrides --- */
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-300 { color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-400 { color: #4ade80 !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-500 { color: #22c55e !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-600 { color: #16a34a !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-700 { color: #15803d !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-500\/50 { color: rgba(34, 197, 94, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-500\/70 { color: rgba(34, 197, 94, 0.7) !important; }
|
||||
[data-hud="matrix"] .hud-zone .text-cyan-500\/80 { color: rgba(34, 197, 94, 0.8) !important; }
|
||||
[data-hud='matrix'] .hud-zone .text-cyan-300 {
|
||||
color: #86efac !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .text-cyan-400 {
|
||||
color: #4ade80 !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .text-cyan-500 {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .text-cyan-600 {
|
||||
color: #16a34a !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .text-cyan-700 {
|
||||
color: #15803d !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .text-cyan-500\/50 {
|
||||
color: rgba(34, 197, 94, 0.5) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .text-cyan-500\/70 {
|
||||
color: rgba(34, 197, 94, 0.7) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .text-cyan-500\/80 {
|
||||
color: rgba(34, 197, 94, 0.8) !important;
|
||||
}
|
||||
|
||||
/* --- Background color overrides --- */
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-400 { background-color: #4ade80 !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-300 { background-color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-500 { background-color: #22c55e !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-500\/10 { background-color: rgba(34, 197, 94, 0.1) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-500\/20 { background-color: rgba(34, 197, 94, 0.2) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-500\/30 { background-color: rgba(34, 197, 94, 0.3) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-900\/30 { background-color: rgba(20, 83, 45, 0.3) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-900\/50 { background-color: rgba(20, 83, 45, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-900\/60 { background-color: rgba(20, 83, 45, 0.6) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-950\/10 { background-color: rgba(5, 46, 22, 0.1) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-950\/30 { background-color: rgba(5, 46, 22, 0.3) !important; }
|
||||
[data-hud="matrix"] .hud-zone .bg-cyan-950\/40 { background-color: rgba(5, 46, 22, 0.4) !important; }
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-400 {
|
||||
background-color: #4ade80 !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-300 {
|
||||
background-color: #86efac !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-500 {
|
||||
background-color: #22c55e !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-500\/10 {
|
||||
background-color: rgba(34, 197, 94, 0.1) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-500\/20 {
|
||||
background-color: rgba(34, 197, 94, 0.2) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-500\/30 {
|
||||
background-color: rgba(34, 197, 94, 0.3) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-900\/30 {
|
||||
background-color: rgba(20, 83, 45, 0.3) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-900\/50 {
|
||||
background-color: rgba(20, 83, 45, 0.5) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-900\/60 {
|
||||
background-color: rgba(20, 83, 45, 0.6) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-950\/10 {
|
||||
background-color: rgba(5, 46, 22, 0.1) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-950\/30 {
|
||||
background-color: rgba(5, 46, 22, 0.3) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .bg-cyan-950\/40 {
|
||||
background-color: rgba(5, 46, 22, 0.4) !important;
|
||||
}
|
||||
|
||||
/* --- Border color overrides --- */
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-400 { border-color: #4ade80 !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500 { border-color: #22c55e !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-700 { border-color: #15803d !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-800 { border-color: #166534 !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-900 { border-color: #14532d !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/10 { border-color: rgba(34, 197, 94, 0.1) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/20 { border-color: rgba(34, 197, 94, 0.2) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/30 { border-color: rgba(34, 197, 94, 0.3) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/40 { border-color: rgba(34, 197, 94, 0.4) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-500\/50 { border-color: rgba(34, 197, 94, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-800\/40 { border-color: rgba(22, 101, 52, 0.4) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-800\/50 { border-color: rgba(22, 101, 52, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-800\/60 { border-color: rgba(22, 101, 52, 0.6) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-cyan-900\/50 { border-color: rgba(20, 83, 45, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-b-cyan-900 { border-bottom-color: #14532d !important; }
|
||||
[data-hud="matrix"] .hud-zone .border-l-cyan-500 { border-left-color: #22c55e !important; }
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-400 {
|
||||
border-color: #4ade80 !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-500 {
|
||||
border-color: #22c55e !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-700 {
|
||||
border-color: #15803d !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-800 {
|
||||
border-color: #166534 !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-900 {
|
||||
border-color: #14532d !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-500\/10 {
|
||||
border-color: rgba(34, 197, 94, 0.1) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-500\/20 {
|
||||
border-color: rgba(34, 197, 94, 0.2) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-500\/30 {
|
||||
border-color: rgba(34, 197, 94, 0.3) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-500\/40 {
|
||||
border-color: rgba(34, 197, 94, 0.4) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-500\/50 {
|
||||
border-color: rgba(34, 197, 94, 0.5) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-800\/40 {
|
||||
border-color: rgba(22, 101, 52, 0.4) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-800\/50 {
|
||||
border-color: rgba(22, 101, 52, 0.5) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-800\/60 {
|
||||
border-color: rgba(22, 101, 52, 0.6) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-cyan-900\/50 {
|
||||
border-color: rgba(20, 83, 45, 0.5) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-b-cyan-900 {
|
||||
border-bottom-color: #14532d !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .border-l-cyan-500 {
|
||||
border-left-color: #22c55e !important;
|
||||
}
|
||||
|
||||
/* --- Hover text --- */
|
||||
[data-hud="matrix"] .hud-zone .hover\:text-cyan-300:hover { color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:text-cyan-400:hover { color: #4ade80 !important; }
|
||||
[data-hud='matrix'] .hud-zone .hover\:text-cyan-300:hover {
|
||||
color: #86efac !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .hover\:text-cyan-400:hover {
|
||||
color: #4ade80 !important;
|
||||
}
|
||||
|
||||
/* --- Hover background --- */
|
||||
[data-hud="matrix"] .hud-zone .hover\:bg-cyan-300:hover { background-color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:bg-cyan-500\/20:hover { background-color: rgba(34, 197, 94, 0.2) !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:bg-cyan-900\/50:hover { background-color: rgba(20, 83, 45, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:bg-cyan-950\/30:hover { background-color: rgba(5, 46, 22, 0.3) !important; }
|
||||
[data-hud='matrix'] .hud-zone .hover\:bg-cyan-300:hover {
|
||||
background-color: #86efac !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .hover\:bg-cyan-500\/20:hover {
|
||||
background-color: rgba(34, 197, 94, 0.2) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .hover\:bg-cyan-900\/50:hover {
|
||||
background-color: rgba(20, 83, 45, 0.5) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .hover\:bg-cyan-950\/30:hover {
|
||||
background-color: rgba(5, 46, 22, 0.3) !important;
|
||||
}
|
||||
|
||||
/* --- Hover border --- */
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-300:hover { border-color: #86efac !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-500:hover { border-color: #22c55e !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-500\/40:hover { border-color: rgba(34, 197, 94, 0.4) !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-500\/50:hover { border-color: rgba(34, 197, 94, 0.5) !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-600:hover { border-color: #16a34a !important; }
|
||||
[data-hud="matrix"] .hud-zone .hover\:border-cyan-800:hover { border-color: #166534 !important; }
|
||||
[data-hud='matrix'] .hud-zone .hover\:border-cyan-300:hover {
|
||||
border-color: #86efac !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .hover\:border-cyan-500:hover {
|
||||
border-color: #22c55e !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .hover\:border-cyan-500\/40:hover {
|
||||
border-color: rgba(34, 197, 94, 0.4) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .hover\:border-cyan-500\/50:hover {
|
||||
border-color: rgba(34, 197, 94, 0.5) !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .hover\:border-cyan-600:hover {
|
||||
border-color: #16a34a !important;
|
||||
}
|
||||
[data-hud='matrix'] .hud-zone .hover\:border-cyan-800:hover {
|
||||
border-color: #166534 !important;
|
||||
}
|
||||
|
||||
/* --- Accent (range inputs) --- */
|
||||
[data-hud="matrix"] .hud-zone .accent-cyan-500 { accent-color: #22c55e !important; }
|
||||
[data-hud='matrix'] .hud-zone .accent-cyan-500 {
|
||||
accent-color: #22c55e !important;
|
||||
}
|
||||
|
||||
/* Focus mode: dim the map canvas (tiles + drawn layers) when a popup is active.
|
||||
Inside MapLibre's DOM, .maplibregl-canvas-container is a SIBLING of .maplibregl-popup,
|
||||
@@ -200,3 +366,94 @@ body {
|
||||
.map-focus-active .maplibregl-popup {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
/* ── INFONET CRT TERMINAL EFFECTS ── */
|
||||
|
||||
.infonet-font {
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
}
|
||||
|
||||
/* CRT scanline overlay — scoped to .crt containers only */
|
||||
.crt {
|
||||
position: relative;
|
||||
animation: crt-flicker 0.15s infinite;
|
||||
text-shadow: 0 0 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.crt::before {
|
||||
content: ' ';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background:
|
||||
linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 0, 0, 0.06),
|
||||
rgba(0, 255, 0, 0.02),
|
||||
rgba(0, 0, 255, 0.06)
|
||||
);
|
||||
z-index: 2;
|
||||
background-size: 100% 2px, 3px 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes crt-flicker {
|
||||
0% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
5% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
10% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.crt {
|
||||
animation: none;
|
||||
}
|
||||
.crt::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ticker animation for InfoNet */
|
||||
@keyframes infonet-ticker {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-ticker {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
animation: infonet-ticker 90s linear infinite;
|
||||
}
|
||||
|
||||
/* Scoped scrollbar for CRT terminal */
|
||||
.crt ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.crt ::-webkit-scrollbar-track {
|
||||
background: #0a0a0a;
|
||||
}
|
||||
.crt ::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
.crt ::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
+16
-22
@@ -1,21 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/lib/ThemeContext";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import type { Metadata } from 'next';
|
||||
import DesktopBridgeBootstrap from '@/components/DesktopBridgeBootstrap';
|
||||
import { ThemeProvider } from '@/lib/ThemeContext';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "WORLDVIEW // ORBITAL TRACKING",
|
||||
description: "Advanced Geopolitical Risk Dashboard",
|
||||
title: 'WORLDVIEW // ORBITAL TRACKING',
|
||||
description: 'Advanced Geopolitical Risk Dashboard',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,12 +15,16 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head />
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[var(--bg-primary)]`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body className="antialiased bg-[var(--bg-primary)]" suppressHydrationWarning>
|
||||
<ThemeProvider>
|
||||
<DesktopBridgeBootstrap />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+945
-331
File diff suppressed because it is too large
Load Diff
@@ -1,345 +1,417 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, X, Check, GripHorizontal } from 'lucide-react';
|
||||
|
||||
interface FilterField {
|
||||
key: string;
|
||||
label: string;
|
||||
options: string[];
|
||||
optionLabels?: Record<string, string>;
|
||||
key: string;
|
||||
label: string;
|
||||
options: string[];
|
||||
optionLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AdvancedFilterModalProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
accentColor: string; // CSS color string e.g. '#00bcd4'
|
||||
accentColorName: string; // tailwind name e.g. 'cyan'
|
||||
fields: FilterField[];
|
||||
activeFilters: Record<string, string[]>;
|
||||
onApply: (filters: Record<string, string[]>) => void;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
accentColor: string; // CSS color string e.g. '#00bcd4'
|
||||
accentColorName: string; // tailwind name e.g. 'cyan'
|
||||
fields: FilterField[];
|
||||
activeFilters: Record<string, string[]>;
|
||||
onApply: (filters: Record<string, string[]>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdvancedFilterModal({
|
||||
title, icon, accentColor, accentColorName, fields, activeFilters, onApply, onClose
|
||||
title,
|
||||
icon,
|
||||
accentColor: _accentColor,
|
||||
accentColorName,
|
||||
fields,
|
||||
activeFilters,
|
||||
onApply,
|
||||
onClose,
|
||||
}: AdvancedFilterModalProps) {
|
||||
// Local draft state — only committed on Apply
|
||||
const [draft, setDraft] = useState<Record<string, Set<string>>>(() => {
|
||||
const init: Record<string, Set<string>> = {};
|
||||
for (const field of fields) {
|
||||
init[field.key] = new Set(activeFilters[field.key] || []);
|
||||
}
|
||||
return init;
|
||||
// Local draft state — only committed on Apply
|
||||
const [draft, setDraft] = useState<Record<string, Set<string>>>(() => {
|
||||
const init: Record<string, Set<string>> = {};
|
||||
for (const field of fields) {
|
||||
init[field.key] = new Set(activeFilters[field.key] || []);
|
||||
}
|
||||
return init;
|
||||
});
|
||||
|
||||
const [searchTerms, setSearchTerms] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
for (const field of fields) init[field.key] = '';
|
||||
return init;
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState(fields[0]?.key || '');
|
||||
|
||||
// Dragging state
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, y: 0, posX: 0, posY: 0 });
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Center on mount, clamped so it doesn't overlap the bottom status bar (~48px)
|
||||
useEffect(() => {
|
||||
if (modalRef.current) {
|
||||
const rect = modalRef.current.getBoundingClientRect();
|
||||
const pad = 52; // status bar + small gap
|
||||
const maxY = window.innerHeight - rect.height - pad;
|
||||
setPosition({
|
||||
x: Math.max(0, (window.innerWidth - rect.width) / 2),
|
||||
y: Math.max(0, Math.min((window.innerHeight - rect.height) / 2, maxY)),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY, posX: position.x, posY: position.y };
|
||||
},
|
||||
[position],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
const dy = e.clientY - dragStartRef.current.y;
|
||||
const newX = dragStartRef.current.posX + dx;
|
||||
const newY = dragStartRef.current.posY + dy;
|
||||
// Clamp so modal can't be dragged below the bottom status bar
|
||||
const maxY = window.innerHeight - 120; // keep at least 120px visible
|
||||
setPosition({
|
||||
x: Math.max(-200, newX),
|
||||
y: Math.max(0, Math.min(newY, maxY)),
|
||||
});
|
||||
};
|
||||
const handleUp = () => setIsDragging(false);
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const toggleItem = (fieldKey: string, value: string) => {
|
||||
setDraft((prev) => {
|
||||
const next = { ...prev };
|
||||
const s = new Set(prev[fieldKey]);
|
||||
if (s.has(value)) s.delete(value);
|
||||
else s.add(value);
|
||||
next[fieldKey] = s;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [searchTerms, setSearchTerms] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
for (const field of fields) init[field.key] = '';
|
||||
return init;
|
||||
const removeChip = (fieldKey: string, value: string) => {
|
||||
setDraft((prev) => {
|
||||
const next = { ...prev };
|
||||
const s = new Set(prev[fieldKey]);
|
||||
s.delete(value);
|
||||
next[fieldKey] = s;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(fields[0]?.key || '');
|
||||
const clearField = (fieldKey: string) => {
|
||||
setDraft((prev) => ({ ...prev, [fieldKey]: new Set<string>() }));
|
||||
};
|
||||
|
||||
// Dragging state
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, y: 0, posX: 0, posY: 0 });
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const clearAll = () => {
|
||||
const cleared: Record<string, Set<string>> = {};
|
||||
for (const f of fields) cleared[f.key] = new Set<string>();
|
||||
setDraft(cleared);
|
||||
};
|
||||
|
||||
// Center on mount
|
||||
useEffect(() => {
|
||||
if (modalRef.current) {
|
||||
const rect = modalRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
x: (window.innerWidth - rect.width) / 2,
|
||||
y: (window.innerHeight - rect.height) / 2
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const handleApply = () => {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, set] of Object.entries(draft)) {
|
||||
if (set.size > 0) result[key] = Array.from(set);
|
||||
}
|
||||
onApply(result);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY, posX: position.x, posY: position.y };
|
||||
}, [position]);
|
||||
const totalSelected = Object.values(draft).reduce((acc, s) => acc + s.size, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
const dy = e.clientY - dragStartRef.current.y;
|
||||
setPosition({
|
||||
x: dragStartRef.current.posX + dx,
|
||||
y: dragStartRef.current.posY + dy
|
||||
});
|
||||
};
|
||||
const handleUp = () => setIsDragging(false);
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
const activeField = fields.find((f) => f.key === activeTab);
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!activeField) return [];
|
||||
const term = (searchTerms[activeTab] || '').toLowerCase();
|
||||
const opts = activeField.options;
|
||||
if (!term) return opts;
|
||||
return opts.filter((o) => {
|
||||
const displayLabel = activeField.optionLabels?.[o] || o;
|
||||
return displayLabel.toLowerCase().includes(term);
|
||||
});
|
||||
}, [activeField, activeTab, searchTerms]);
|
||||
|
||||
const toggleItem = (fieldKey: string, value: string) => {
|
||||
setDraft(prev => {
|
||||
const next = { ...prev };
|
||||
const s = new Set(prev[fieldKey]);
|
||||
if (s.has(value)) s.delete(value);
|
||||
else s.add(value);
|
||||
next[fieldKey] = s;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// Tailwind color map for dynamic classes
|
||||
const colorMap: Record<
|
||||
string,
|
||||
{ text: string; bg: string; bgHover: string; border: string; ring: string }
|
||||
> = {
|
||||
cyan: {
|
||||
text: 'text-cyan-400',
|
||||
bg: 'bg-cyan-500/10',
|
||||
bgHover: 'hover:bg-cyan-500/15',
|
||||
border: 'border-cyan-500/30',
|
||||
ring: 'ring-cyan-500/50',
|
||||
},
|
||||
orange: {
|
||||
text: 'text-orange-400',
|
||||
bg: 'bg-orange-500/10',
|
||||
bgHover: 'hover:bg-orange-500/15',
|
||||
border: 'border-orange-500/30',
|
||||
ring: 'ring-orange-500/50',
|
||||
},
|
||||
yellow: {
|
||||
text: 'text-yellow-400',
|
||||
bg: 'bg-yellow-500/10',
|
||||
bgHover: 'hover:bg-yellow-500/15',
|
||||
border: 'border-yellow-500/30',
|
||||
ring: 'ring-yellow-500/50',
|
||||
},
|
||||
pink: {
|
||||
text: 'text-pink-400',
|
||||
bg: 'bg-pink-500/10',
|
||||
bgHover: 'hover:bg-pink-500/15',
|
||||
border: 'border-pink-500/30',
|
||||
ring: 'ring-pink-500/50',
|
||||
},
|
||||
blue: {
|
||||
text: 'text-blue-400',
|
||||
bg: 'bg-blue-500/10',
|
||||
bgHover: 'hover:bg-blue-500/15',
|
||||
border: 'border-blue-500/30',
|
||||
ring: 'ring-blue-500/50',
|
||||
},
|
||||
};
|
||||
const c = colorMap[accentColorName] || colorMap.cyan;
|
||||
|
||||
const removeChip = (fieldKey: string, value: string) => {
|
||||
setDraft(prev => {
|
||||
const next = { ...prev };
|
||||
const s = new Set(prev[fieldKey]);
|
||||
s.delete(value);
|
||||
next[fieldKey] = s;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] pointer-events-auto"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
|
||||
|
||||
const clearField = (fieldKey: string) => {
|
||||
setDraft(prev => ({ ...prev, [fieldKey]: new Set<string>() }));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
const cleared: Record<string, Set<string>> = {};
|
||||
for (const f of fields) cleared[f.key] = new Set<string>();
|
||||
setDraft(cleared);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, set] of Object.entries(draft)) {
|
||||
if (set.size > 0) result[key] = Array.from(set);
|
||||
}
|
||||
onApply(result);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const totalSelected = Object.values(draft).reduce((acc, s) => acc + s.size, 0);
|
||||
|
||||
const activeField = fields.find(f => f.key === activeTab);
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!activeField) return [];
|
||||
const term = (searchTerms[activeTab] || '').toLowerCase();
|
||||
const opts = activeField.options;
|
||||
if (!term) return opts;
|
||||
return opts.filter(o => {
|
||||
const displayLabel = activeField.optionLabels?.[o] || o;
|
||||
return displayLabel.toLowerCase().includes(term);
|
||||
});
|
||||
}, [activeField, activeTab, searchTerms]);
|
||||
|
||||
const accentBorder = `border-[${accentColor}]/30`;
|
||||
|
||||
// Tailwind color map for dynamic classes
|
||||
const colorMap: Record<string, { text: string; bg: string; bgHover: string; border: string; ring: string }> = {
|
||||
cyan: { text: 'text-cyan-400', bg: 'bg-cyan-500/10', bgHover: 'hover:bg-cyan-500/15', border: 'border-cyan-500/30', ring: 'ring-cyan-500/50' },
|
||||
orange: { text: 'text-orange-400', bg: 'bg-orange-500/10', bgHover: 'hover:bg-orange-500/15', border: 'border-orange-500/30', ring: 'ring-orange-500/50' },
|
||||
yellow: { text: 'text-yellow-400', bg: 'bg-yellow-500/10', bgHover: 'hover:bg-yellow-500/15', border: 'border-yellow-500/30', ring: 'ring-yellow-500/50' },
|
||||
pink: { text: 'text-pink-400', bg: 'bg-pink-500/10', bgHover: 'hover:bg-pink-500/15', border: 'border-pink-500/30', ring: 'ring-pink-500/50' },
|
||||
blue: { text: 'text-blue-400', bg: 'bg-blue-500/10', bgHover: 'hover:bg-blue-500/15', border: 'border-blue-500/30', ring: 'ring-blue-500/50' },
|
||||
};
|
||||
const c = colorMap[accentColorName] || colorMap.cyan;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] pointer-events-auto" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: 480,
|
||||
userSelect: isDragging ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.92 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
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-[var(--border-primary)]/60 select-none flex-shrink-0"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GripHorizontal size={14} className="text-[var(--text-muted)]" />
|
||||
{icon}
|
||||
<span className={`text-[11px] ${c.text} tracking-[0.25em] font-semibold`}>{title}</span>
|
||||
{totalSelected > 0 && (
|
||||
<span className={`text-[9px] ${c.bg} ${c.text} px-1.5 py-0.5 rounded-sm`}>
|
||||
{totalSelected} SELECTED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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-[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;
|
||||
return (
|
||||
<button
|
||||
key={field.key}
|
||||
onClick={() => setActiveTab(field.key)}
|
||||
className={`px-3 py-1.5 text-[9px] tracking-widest rounded-t transition-colors relative ${isActive
|
||||
? `${c.bg} ${c.text} border border-b-0 ${c.border}`
|
||||
: 'text-gray-500 hover:text-gray-300 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{field.label}
|
||||
{count > 0 && (
|
||||
<span className={`ml-1.5 text-[8px] ${c.text} bg-black/40 px-1 rounded`}>{count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Selected Chips ── */}
|
||||
{activeField && draft[activeTab]?.size > 0 && (
|
||||
<div className="px-4 pt-3 pb-1 flex flex-wrap gap-1.5 flex-shrink-0 max-h-20 overflow-y-auto styled-scrollbar">
|
||||
{Array.from(draft[activeTab]).map(val => {
|
||||
const displayVal = activeField.optionLabels?.[val] || val;
|
||||
return (
|
||||
<span
|
||||
key={val}
|
||||
className={`inline-flex items-center gap-1 text-[9px] ${c.bg} ${c.text} border ${c.border} rounded-full px-2 py-0.5 group`}
|
||||
>
|
||||
{displayVal.length > 28 ? displayVal.slice(0, 28) + '…' : displayVal}
|
||||
<button
|
||||
onClick={() => removeChip(activeTab, val)}
|
||||
className="opacity-50 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => clearField(activeTab)}
|
||||
className="text-[8px] text-red-400/70 hover:text-red-300 tracking-widest ml-1"
|
||||
>
|
||||
CLEAR
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Search Bar ── */}
|
||||
<div className="px-4 pt-3 pb-2 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-600" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerms[activeTab] || ''}
|
||||
onChange={(e) => setSearchTerms(prev => ({ ...prev, [activeTab]: e.target.value }))}
|
||||
placeholder={`Search ${activeField?.label.toLowerCase() || ''}...`}
|
||||
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] && (
|
||||
<button
|
||||
onClick={() => setSearchTerms(prev => ({ ...prev, [activeTab]: '' }))}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-300"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
|
||||
{filteredOptions.length} AVAILABLE
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
|
||||
{draft[activeTab]?.size || 0} SELECTED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 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-[var(--text-muted)] text-[10px] tracking-widest">
|
||||
NO MATCHING RESULTS
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-px">
|
||||
{filteredOptions.map((option) => {
|
||||
const isChecked = draft[activeTab]?.has(option);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
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-[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-[var(--border-primary)] group-hover:border-[var(--border-secondary)]'
|
||||
}`}>
|
||||
{isChecked && <Check size={9} strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="text-[10px] tracking-wide truncate">
|
||||
{activeField?.optionLabels?.[option] || option}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<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"
|
||||
>
|
||||
CLEAR ALL
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
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>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className={`text-[9px] ${c.text} tracking-widest border ${c.border} rounded-md px-4 py-1.5 ${c.bg} ${c.bgHover} transition-all font-semibold`}
|
||||
>
|
||||
APPLY{totalSelected > 0 ? ` (${totalSelected})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Modal */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: 480,
|
||||
userSelect: isDragging ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.92 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`bg-[var(--bg-secondary)]/95 backdrop-blur-sm border ${c.border} shadow-[0_8px_60px_rgba(0,0,0,0.3)] flex flex-col font-mono overflow-hidden`}
|
||||
style={{ maxHeight: 'calc(100vh - 80px)' }}
|
||||
>
|
||||
{/* ── Title Bar (Draggable) ── */}
|
||||
<div
|
||||
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-[var(--text-muted)]" />
|
||||
{icon}
|
||||
<span className={`text-[11px] ${c.text} tracking-[0.25em] font-semibold`}>
|
||||
{title}
|
||||
</span>
|
||||
{totalSelected > 0 && (
|
||||
<span className={`text-[9px] ${c.bg} ${c.text} px-1.5 py-0.5 rounded-sm`}>
|
||||
{totalSelected} SELECTED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors p-1 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-[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;
|
||||
return (
|
||||
<button
|
||||
key={field.key}
|
||||
onClick={() => setActiveTab(field.key)}
|
||||
className={`px-3 py-1.5 text-[9px] tracking-widest rounded-t transition-colors relative ${
|
||||
isActive
|
||||
? `${c.bg} ${c.text} border border-b-0 ${c.border}`
|
||||
: 'text-gray-500 hover:text-gray-300 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{field.label}
|
||||
{count > 0 && (
|
||||
<span className={`ml-1.5 text-[8px] ${c.text} bg-black/40 px-1`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Selected Chips ── */}
|
||||
{activeField && draft[activeTab]?.size > 0 && (
|
||||
<div className="px-4 pt-3 pb-1 flex flex-wrap gap-1.5 flex-shrink-0 max-h-20 overflow-y-auto styled-scrollbar">
|
||||
{Array.from(draft[activeTab]).map((val) => {
|
||||
const displayVal = activeField.optionLabels?.[val] || val;
|
||||
return (
|
||||
<span
|
||||
key={val}
|
||||
className={`inline-flex items-center gap-1 text-[9px] ${c.bg} ${c.text} border ${c.border} rounded-full px-2 py-0.5 group`}
|
||||
>
|
||||
{displayVal.length > 28 ? displayVal.slice(0, 28) + '…' : displayVal}
|
||||
<button
|
||||
onClick={() => removeChip(activeTab, val)}
|
||||
className="opacity-50 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => clearField(activeTab)}
|
||||
className="text-[8px] text-red-400/70 hover:text-red-300 tracking-widest ml-1"
|
||||
>
|
||||
CLEAR
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Search Bar ── */}
|
||||
<div className="px-4 pt-3 pb-2 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-600"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerms[activeTab] || ''}
|
||||
onChange={(e) =>
|
||||
setSearchTerms((prev) => ({ ...prev, [activeTab]: e.target.value }))
|
||||
}
|
||||
placeholder={`Search ${activeField?.label.toLowerCase() || ''}...`}
|
||||
className={`w-full bg-[var(--bg-primary)]/50 border border-[var(--border-primary)]/70 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] && (
|
||||
<button
|
||||
onClick={() => setSearchTerms((prev) => ({ ...prev, [activeTab]: '' }))}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-300"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
|
||||
{filteredOptions.length} AVAILABLE
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
|
||||
{draft[activeTab]?.size || 0} SELECTED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 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-[var(--text-muted)] text-[10px] tracking-widest">
|
||||
NO MATCHING RESULTS
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-px">
|
||||
{filteredOptions.map((option) => {
|
||||
const isChecked = draft[activeTab]?.has(option);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => toggleItem(activeTab, option)}
|
||||
className={`flex items-center gap-2.5 px-3 py-1.5 text-left transition-all group ${
|
||||
isChecked
|
||||
? `${c.bg} ${c.text}`
|
||||
: `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-[var(--border-primary)] group-hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{isChecked && <Check size={9} strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="text-[10px] tracking-wide truncate">
|
||||
{activeField?.optionLabels?.[option] || option}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<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"
|
||||
>
|
||||
CLEAR ALL
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[9px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] tracking-widest border border-[var(--border-primary)] px-4 py-1.5 hover:bg-[var(--bg-tertiary)]/50 transition-all"
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className={`text-[9px] ${c.text} tracking-widest border ${c.border} px-4 py-1.5 ${c.bg} ${c.bgHover} transition-all font-semibold`}
|
||||
>
|
||||
APPLY{totalSelected > 0 ? ` (${totalSelected})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,198 +1,385 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Zap, Ship, Download, Shield, Bug, Heart } from "lucide-react";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
Terminal,
|
||||
Radio,
|
||||
Camera,
|
||||
Search,
|
||||
TrainFront,
|
||||
Globe,
|
||||
Shield,
|
||||
Bug,
|
||||
Heart,
|
||||
} from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = "0.9.5";
|
||||
const CURRENT_VERSION = '0.9.6';
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
const RELEASE_TITLE = 'InfoNet Experimental Testnet — Decentralized Intelligence Experiment';
|
||||
|
||||
const HEADLINE_FEATURE = {
|
||||
icon: <Terminal size={16} className="text-cyan-400" />,
|
||||
title: 'InfoNet Experimental Testnet is Live',
|
||||
subtitle: 'The first decentralized intelligence mesh built directly into an OSINT platform. This is an experimental testnet — NOT a privacy tool.',
|
||||
details: [
|
||||
'A global, obfuscated message relay running inside ShadowBroker. Anyone with the dashboard can transmit and receive on the InfoNet — no accounts, no signup, no identity required.',
|
||||
'Messages pass through a Wormhole relay layer with gate personas, canonical payload signing, and message obfuscation. Transport is obfuscated to a degree, but this is NOT private communication. Do not transmit anything you would not say in public. End-to-end encryption is being developed but is not yet implemented.',
|
||||
'Dead Drop inbox for peer-to-peer message exchange. Mesh Terminal CLI for power users. Gate persona system for pseudonymous identity. Double-ratchet DM scaffolding in progress.',
|
||||
'Nothing like this has existed in an OSINT tool before. This is an open experiment — jump on the testnet, explore the protocol, and help shape what decentralized intelligence looks like.',
|
||||
],
|
||||
callToAction: 'OPEN MESH CHAT \u2192 MESH TAB \u2192 START TRANSMITTING',
|
||||
};
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Zap size={14} className="text-cyan-400" />,
|
||||
title: "Parallelized Boot (15s Cold Start)",
|
||||
desc: "Backend startup now runs fast-tier, slow-tier, and airport data concurrently via ThreadPoolExecutor. Boot time cut from 60s+ to ~15s.",
|
||||
color: "cyan",
|
||||
},
|
||||
{
|
||||
icon: <Shield size={14} className="text-green-400" />,
|
||||
title: "Adaptive Polling + ETag Caching",
|
||||
desc: "Data polling engine rebuilt with adaptive retry (3s startup, 15s steady state) and ETag conditional caching. Map panning no longer interrupts data flow.",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
icon: <Ship size={14} className="text-blue-400" />,
|
||||
title: "Sliding Edge Panels (LAYERS / INTEL)",
|
||||
desc: "Replaced bulky Record Panel with spring-animated side tabs. LAYERS on the left, INTEL (News, Markets, Radio, Find) on the right. Premium tactical HUD feel.",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
icon: <Download size={14} className="text-yellow-400" />,
|
||||
title: "Admin Auth + Rate Limiting + Auto-Updater",
|
||||
desc: "Settings and system endpoints protected by X-Admin-Key. All endpoints rate-limited via slowapi. One-click auto-update from GitHub releases with safe backup/restart.",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
icon: <Shield size={14} className="text-purple-400" />,
|
||||
title: "Docker Swarm Secrets Support",
|
||||
desc: "Production deployments can now load API keys from /run/secrets/ instead of environment variables. env_check.py enforces warning tiers for missing keys.",
|
||||
color: "purple",
|
||||
},
|
||||
{
|
||||
icon: <Radio size={14} className="text-amber-400" />,
|
||||
title: 'Meshtastic + APRS Radio Integration',
|
||||
desc: 'Live Meshtastic mesh radio nodes plotted worldwide via MQTT. APRS amateur radio positioning via APRS-IS TCP feed. Both integrated into Mesh Chat and the SIGINT grid. Note: Mesh radio is NOT private — RF transmissions are public by nature.',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
icon: <Terminal size={14} className="text-cyan-400" />,
|
||||
title: 'Mesh Terminal',
|
||||
desc: 'Built-in command-line interface. Send messages, DMs, run market commands, inspect gate state. Draggable panel, minimizes to the top bar. Type "help" to see everything.',
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
icon: <Search size={14} className="text-green-400" />,
|
||||
title: 'Shodan Device Search',
|
||||
desc: 'Query Shodan directly from ShadowBroker. Search internet-connected devices by keyword, CVE, or port — results plotted as a live overlay on the map with configurable marker style.',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: <Camera size={14} className="text-emerald-400" />,
|
||||
title: 'CCTV Mesh Expanded — 12 Sources, 11,000+ Cameras',
|
||||
desc: 'Massive expansion: added Spain (DGT national + Madrid city), California (12 Caltrans districts), Washington State, Georgia, Illinois, Michigan, and Windy Webcams. Now covers 6 countries. Enabled by default.',
|
||||
color: 'emerald',
|
||||
},
|
||||
{
|
||||
icon: <TrainFront size={14} className="text-blue-400" />,
|
||||
title: 'Train Tracking (Amtrak + European Rail)',
|
||||
desc: 'Real-time Amtrak train positions across the US and European rail via DigiTraffic. Speed, heading, route, and status for every train on the network.',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
icon: <Globe size={14} className="text-purple-400" />,
|
||||
title: '8 New Intelligence Layers',
|
||||
desc: 'Volcanoes (Smithsonian), air quality PM2.5 (OpenAQ), severe weather alerts, fishing activity (Global Fishing Watch), military bases, 35K+ power plants, SatNOGS ground stations, TinyGS LoRa satellites, VIIRS nightlights.',
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
icon: <Shield size={14} className="text-yellow-400" />,
|
||||
title: 'Sentinel Hub Imagery + Desktop Shell Scaffold',
|
||||
desc: 'Copernicus CDSE satellite imagery via Sentinel Hub Process API with OAuth2 token flow. Desktop-native control routing scaffold (pre-Tauri) with session profiles and audit trail.',
|
||||
color: 'yellow',
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
"Fixed start.sh: added missing `fi` after UV install block — valid bash again; setup runs whether or not uv was preinstalled (2026-03-26)",
|
||||
"Stable entity IDs for GDELT & News popups — no more wrong popup after data refresh (PR #63)",
|
||||
"useCallback optimization for interpolation functions — eliminates redundant React re-renders on every 1s tick",
|
||||
"Restored missing GDELT and datacenter background refreshes in slow-tier loop",
|
||||
"Server-side viewport bounding box filtering reduces JSON payload size by 80%+",
|
||||
"Modular fetcher architecture sustained over monolithic data_fetcher.py",
|
||||
"CCTV ingestors instantiated once at startup — no more fresh DB connections every 10min tick",
|
||||
'CCTV auto-seed fix — partial DB (4 of 12 sources) no longer silently skips the other 8 ingestors on startup',
|
||||
'SQLite threading fix — CCTV ingestors no longer share connections across threads',
|
||||
'CCTV layer now ON by default and participates in the All On/Off global toggle',
|
||||
'KiwiSDR, FIRMS fires, internet outages, data centers all switched to ON by default',
|
||||
'Terminal minimized tab repositioned to top-center with proper icon (no more phantom cursor)',
|
||||
'Mesh Chat defaults to MESH tab on startup instead of locked INFONET gate',
|
||||
];
|
||||
|
||||
const CONTRIBUTORS = [
|
||||
{ name: "@imqdcr", desc: "Ship toggle split into 4 categories + stable MMSI/callsign entity IDs for map markers" },
|
||||
{ name: "@csysp", desc: "Dismissible threat alerts + stable entity IDs for GDELT & News popups", pr: "#48, #63" },
|
||||
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min \u2192 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
|
||||
{
|
||||
name: '@wa1id',
|
||||
desc: 'CCTV ingestion fix — fresh SQLite connections per ingest, persistent DB path, startup hydration, cluster clickability',
|
||||
pr: '#92',
|
||||
},
|
||||
{
|
||||
name: '@AlborzNazari',
|
||||
desc: 'Spain DGT + Madrid CCTV sources and STIX 2.1 threat intelligence export endpoint',
|
||||
pr: '#91',
|
||||
},
|
||||
{
|
||||
name: '@adust09',
|
||||
desc: 'Power plants layer, East Asia intel coverage (JSDF bases, ICAO enrichment, Taiwan news sources, military classification)',
|
||||
pr: '#71, #72, #76, #77, #87',
|
||||
},
|
||||
{
|
||||
name: '@Xpirix',
|
||||
desc: 'LocateBar style and interaction improvements',
|
||||
pr: '#78',
|
||||
},
|
||||
{
|
||||
name: '@imqdcr',
|
||||
desc: 'Ship toggle split into 4 categories + stable MMSI/callsign entity IDs for map markers',
|
||||
pr: '#52',
|
||||
},
|
||||
{
|
||||
name: '@csysp',
|
||||
desc: 'Dismissible threat alerts + stable entity IDs for GDELT & News popups + UI declutter',
|
||||
pr: '#48, #61, #63',
|
||||
},
|
||||
{
|
||||
name: '@suranyami',
|
||||
desc: 'Parallel multi-arch Docker builds (11min \u2192 3min) + runtime BACKEND_URL fix',
|
||||
pr: '#35, #44',
|
||||
},
|
||||
{
|
||||
name: '@chr0n1x',
|
||||
desc: 'Kubernetes / Helm chart architecture for high-availability deployments',
|
||||
},
|
||||
{
|
||||
name: '@johan-martensson',
|
||||
desc: 'COSMO-SkyMed satellite classification fix + yfinance batch download optimization',
|
||||
pr: '#96, #98',
|
||||
},
|
||||
{
|
||||
name: '@singularfailure',
|
||||
desc: 'Spanish CCTV feeds + image loading fix',
|
||||
pr: '#93',
|
||||
},
|
||||
{
|
||||
name: '@smithbh',
|
||||
desc: 'Makefile-based taskrunner with LAN/local access options',
|
||||
pr: '#103',
|
||||
},
|
||||
{
|
||||
name: '@OrfeoTerkuci',
|
||||
desc: 'UV project management setup',
|
||||
pr: '#102',
|
||||
},
|
||||
{
|
||||
name: '@deuza',
|
||||
desc: 'dos2unix fix for Mac/Linux quick start',
|
||||
pr: '#101',
|
||||
},
|
||||
{
|
||||
name: '@tm-const',
|
||||
desc: 'CI/CD workflow updates',
|
||||
pr: '#108, #109',
|
||||
},
|
||||
{
|
||||
name: '@Elhard1',
|
||||
desc: 'start.sh shell script fix',
|
||||
pr: '#111',
|
||||
},
|
||||
{
|
||||
name: '@ttulttul',
|
||||
desc: 'Podman compose support + frontend production CSS fix',
|
||||
pr: '#23',
|
||||
},
|
||||
];
|
||||
|
||||
export function useChangelog() {
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
const seen = localStorage.getItem(STORAGE_KEY);
|
||||
if (!seen) setShow(true);
|
||||
}, []);
|
||||
return { showChangelog: show, setShowChangelog: setShow };
|
||||
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;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ChangelogModal = React.memo(function ChangelogModal({ onClose }: ChangelogModalProps) {
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
onClose();
|
||||
};
|
||||
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>
|
||||
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-[620px] max-h-[90vh] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 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 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>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
<p className="text-[9px] text-cyan-500/70 font-mono tracking-widest mt-1">
|
||||
{RELEASE_TITLE.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-8 h-8 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-5">
|
||||
{/* === HEADLINE: InfoNet Testnet === */}
|
||||
<div className="border border-cyan-500/30 bg-cyan-950/20 p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 border border-cyan-500/40 bg-cyan-500/10 flex items-center justify-center flex-shrink-0">
|
||||
{HEADLINE_FEATURE.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-mono text-cyan-300 font-bold tracking-wide">
|
||||
{HEADLINE_FEATURE.title}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-cyan-500/80 mt-0.5">
|
||||
{HEADLINE_FEATURE.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{HEADLINE_FEATURE.details.map((para, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="text-[9px] font-mono text-[var(--text-secondary)] leading-relaxed"
|
||||
>
|
||||
{para}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Testnet disclaimer */}
|
||||
<div className="flex items-start gap-2 p-2.5 border border-red-500/30 bg-red-950/20">
|
||||
<span className="text-red-400 text-[10px] mt-0.5 flex-shrink-0 font-bold">!!</span>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] font-mono text-red-400/90 leading-relaxed block font-bold">
|
||||
EXPERIMENTAL TESTNET — NO PRIVACY GUARANTEE
|
||||
</span>
|
||||
<span className="text-[8px] font-mono text-amber-400/80 leading-relaxed block">
|
||||
InfoNet messages are obfuscated but NOT encrypted end-to-end. The Mesh network
|
||||
(Meshtastic/APRS) is NOT private — radio transmissions are inherently
|
||||
public. Do not send anything sensitive on any channel. Privacy and E2E encryption
|
||||
are actively being developed. Treat all channels as open and public for now.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="text-center pt-1">
|
||||
<span className="text-[8px] font-mono text-cyan-400 tracking-[0.25em] font-bold">
|
||||
{HEADLINE_FEATURE.callToAction}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === Other 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 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 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>
|
||||
{c.pr && (
|
||||
<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 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;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { bootstrapDesktopControlBridge } from '@/lib/desktopBridge';
|
||||
|
||||
export default function DesktopBridgeBootstrap() {
|
||||
useEffect(() => {
|
||||
bootstrapDesktopControlBridge();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,52 +1,58 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
name?: string;
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error(`[ErrorBoundary${this.props.name ? `: ${this.props.name}` : ""}]`, error, errorInfo);
|
||||
}
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error(
|
||||
`[ErrorBoundary${this.props.name ? `: ${this.props.name}` : ''}]`,
|
||||
error,
|
||||
errorInfo,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
return (
|
||||
<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-[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"
|
||||
>
|
||||
RETRY
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4 bg-red-950/40 border border-red-800 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-[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"
|
||||
>
|
||||
RETRY
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import Image, { type ImageLoaderProps, type ImageProps } from 'next/image';
|
||||
|
||||
const passthroughLoader = ({ src }: ImageLoaderProps) => src;
|
||||
|
||||
type ExternalImageProps = Omit<ImageProps, 'loader'> & {
|
||||
unoptimized?: boolean;
|
||||
};
|
||||
|
||||
export default function ExternalImage({
|
||||
unoptimized = true,
|
||||
alt = '',
|
||||
fill,
|
||||
width,
|
||||
height,
|
||||
...rest
|
||||
}: ExternalImageProps) {
|
||||
if (fill) {
|
||||
return (
|
||||
<Image loader={passthroughLoader} unoptimized={unoptimized} fill alt={alt} {...rest} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
loader={passthroughLoader}
|
||||
unoptimized={unoptimized}
|
||||
width={width ?? 640}
|
||||
height={height ?? 360}
|
||||
alt={alt}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,338 +1,398 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronUp, Filter, Plane, Shield, Star, Ship, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Filter,
|
||||
Plane,
|
||||
Shield,
|
||||
Star,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import AdvancedFilterModal from './AdvancedFilterModal';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import { airlineNames } from '../lib/airlineCodes';
|
||||
import { trackedCategories, trackedOperators } from '../lib/trackedData';
|
||||
|
||||
interface FilterPanelProps {
|
||||
data: any;
|
||||
activeFilters: Record<string, string[]>;
|
||||
setActiveFilters: (filters: Record<string, string[]>) => void;
|
||||
activeFilters: Record<string, string[]>;
|
||||
setActiveFilters: (filters: Record<string, string[]>) => void;
|
||||
}
|
||||
|
||||
type ModalConfig = {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
accentColor: string;
|
||||
accentColorName: string;
|
||||
fields: { key: string; label: string; options: string[]; optionLabels?: Record<string, string> }[];
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
accentColor: string;
|
||||
accentColorName: string;
|
||||
fields: {
|
||||
key: string;
|
||||
label: string;
|
||||
options: string[];
|
||||
optionLabels?: Record<string, string>;
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function FilterPanel({ data, activeFilters, setActiveFilters }: FilterPanelProps) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [openModal, setOpenModal] = useState<string | null>(null);
|
||||
const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFilters }: FilterPanelProps) {
|
||||
const data = useDataKeys(['commercial_flights', 'private_flights', 'private_jets', 'military_flights', 'tracked_flights', 'ships'] as const);
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [openModal, setOpenModal] = useState<string | null>(null);
|
||||
|
||||
// ── Extract unique values from live data ──
|
||||
// ── Extract unique values from live data ──
|
||||
|
||||
// Commercial: departures, arrivals, airlines
|
||||
const uniqueOrigins = useMemo(() => {
|
||||
const origins = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.origin_name && f.origin_name !== 'UNKNOWN') origins.add(f.origin_name);
|
||||
}
|
||||
return Array.from(origins).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
// Commercial: departures, arrivals, airlines
|
||||
const uniqueOrigins = useMemo(() => {
|
||||
const origins = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.origin_name && f.origin_name !== 'UNKNOWN') origins.add(f.origin_name);
|
||||
}
|
||||
return Array.from(origins).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
|
||||
const uniqueDestinations = useMemo(() => {
|
||||
const dests = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.dest_name && f.dest_name !== 'UNKNOWN') dests.add(f.dest_name);
|
||||
}
|
||||
return Array.from(dests).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
const uniqueDestinations = useMemo(() => {
|
||||
const dests = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.dest_name && f.dest_name !== 'UNKNOWN') dests.add(f.dest_name);
|
||||
}
|
||||
return Array.from(dests).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
|
||||
const uniqueAirlines = useMemo(() => {
|
||||
const airlines = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.airline_code && f.airline_code.trim()) airlines.add(f.airline_code.trim());
|
||||
}
|
||||
return Array.from(airlines).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
const uniqueAirlines = useMemo(() => {
|
||||
const airlines = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.airline_code && f.airline_code.trim()) airlines.add(f.airline_code.trim());
|
||||
}
|
||||
return Array.from(airlines).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
|
||||
const airlineLabels = useMemo(() => {
|
||||
const labels: Record<string, string> = {};
|
||||
for (const code of uniqueAirlines) {
|
||||
const name = airlineNames[code];
|
||||
if (name) {
|
||||
labels[code] = `${code} - ${name}`;
|
||||
} else {
|
||||
labels[code] = code;
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}, [uniqueAirlines]);
|
||||
const airlineLabels = useMemo(() => {
|
||||
const labels: Record<string, string> = {};
|
||||
for (const code of uniqueAirlines) {
|
||||
const name = airlineNames[code];
|
||||
if (name) {
|
||||
labels[code] = `${code} - ${name}`;
|
||||
} else {
|
||||
labels[code] = code;
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}, [uniqueAirlines]);
|
||||
|
||||
// Private: callsigns + aircraft types
|
||||
const uniquePrivateCallsigns = useMemo(() => {
|
||||
const callsigns = new Set<string>();
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
if (f.callsign) callsigns.add(f.callsign);
|
||||
if (f.registration) callsigns.add(f.registration);
|
||||
}
|
||||
return Array.from(callsigns).sort();
|
||||
}, [data?.private_flights, data?.private_jets]);
|
||||
// Private: callsigns + aircraft types
|
||||
const uniquePrivateCallsigns = useMemo(() => {
|
||||
const callsigns = new Set<string>();
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
if (f.callsign) callsigns.add(f.callsign);
|
||||
if (f.registration) callsigns.add(f.registration);
|
||||
}
|
||||
return Array.from(callsigns).sort();
|
||||
}, [data?.private_flights, data?.private_jets]);
|
||||
|
||||
const uniquePrivateAircraftTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
if (f.model && f.model !== 'Unknown') types.add(f.model);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.private_flights, data?.private_jets]);
|
||||
const uniquePrivateAircraftTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
if (f.model && f.model !== 'Unknown') types.add(f.model);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.private_flights, data?.private_jets]);
|
||||
|
||||
// Military: country + aircraft type
|
||||
const uniqueMilCountries = useMemo(() => {
|
||||
const countries = new Set<string>();
|
||||
for (const f of data?.military_flights || []) {
|
||||
if (f.country) countries.add(f.country);
|
||||
else if (f.registration) countries.add(f.registration);
|
||||
}
|
||||
return Array.from(countries).sort();
|
||||
}, [data?.military_flights]);
|
||||
// Military: country + aircraft type
|
||||
const uniqueMilCountries = useMemo(() => {
|
||||
const countries = new Set<string>();
|
||||
for (const f of data?.military_flights || []) {
|
||||
if (f.country) countries.add(f.country);
|
||||
else if (f.registration) countries.add(f.registration);
|
||||
}
|
||||
return Array.from(countries).sort();
|
||||
}, [data?.military_flights]);
|
||||
|
||||
const uniqueMilAircraftTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const f of data?.military_flights || []) {
|
||||
if (f.military_type && f.military_type !== 'default') types.add(f.military_type);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.military_flights]);
|
||||
const uniqueMilAircraftTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const f of data?.military_flights || []) {
|
||||
if (f.military_type && f.military_type !== 'default') types.add(f.military_type);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.military_flights]);
|
||||
|
||||
// Tracked: operators + categories
|
||||
const uniqueTrackedOperators = useMemo(() => {
|
||||
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_tags) ops.add(f.alert_tags);
|
||||
}
|
||||
return Array.from(ops).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
// Tracked: operators + categories
|
||||
const uniqueTrackedOperators = useMemo(() => {
|
||||
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_tags) for (const t of f.alert_tags) ops.add(t);
|
||||
}
|
||||
return Array.from(ops).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
const uniqueTrackedCategories = useMemo(() => {
|
||||
const cats = new Set<string>(trackedCategories);
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
if (f.alert_category) cats.add(f.alert_category);
|
||||
}
|
||||
return Array.from(cats).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
const uniqueTrackedCategories = useMemo(() => {
|
||||
const cats = new Set<string>(trackedCategories);
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
if (f.alert_category) cats.add(f.alert_category);
|
||||
}
|
||||
return Array.from(cats).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
// Maritime: vessel names + vessel types (using 'type' field, not 'ship_type')
|
||||
const uniqueShipNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const s of data?.ships || []) {
|
||||
if (s.name && s.name !== 'UNKNOWN') names.add(s.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
}, [data?.ships]);
|
||||
// Maritime: vessel names + vessel types (using 'type' field, not 'ship_type')
|
||||
const uniqueShipNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const s of data?.ships || []) {
|
||||
if (s.name && s.name !== 'UNKNOWN') names.add(s.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
}, [data?.ships]);
|
||||
|
||||
const uniqueVesselTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const s of data?.ships || []) {
|
||||
// Use 'type' field from AIS stream (tanker, cargo, passenger, yacht, etc.)
|
||||
if (s.type && s.type !== 'unknown') types.add(s.type);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.ships]);
|
||||
const uniqueVesselTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const s of data?.ships || []) {
|
||||
// Use 'type' field from AIS stream (tanker, cargo, passenger, yacht, etc.)
|
||||
if (s.type && s.type !== 'unknown') types.add(s.type);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.ships]);
|
||||
|
||||
// ── Modal configs ──
|
||||
// ── Modal configs ──
|
||||
|
||||
const modalConfigs: Record<string, ModalConfig> = {
|
||||
commercial: {
|
||||
title: 'COMMERCIAL FLIGHTS',
|
||||
icon: <Plane size={13} className="text-cyan-400" />,
|
||||
accentColor: '#00bcd4',
|
||||
accentColorName: 'cyan',
|
||||
fields: [
|
||||
{ key: 'commercial_departure', label: 'DEPARTURE', options: uniqueOrigins },
|
||||
{ key: 'commercial_arrival', label: 'ARRIVAL', options: uniqueDestinations },
|
||||
{ key: 'commercial_airline', label: 'AIRLINE', options: uniqueAirlines, optionLabels: airlineLabels },
|
||||
]
|
||||
const modalConfigs: Record<string, ModalConfig> = {
|
||||
commercial: {
|
||||
title: 'COMMERCIAL FLIGHTS',
|
||||
icon: <Plane size={13} className="text-cyan-400" />,
|
||||
accentColor: '#00bcd4',
|
||||
accentColorName: 'cyan',
|
||||
fields: [
|
||||
{ key: 'commercial_departure', label: 'DEPARTURE', options: uniqueOrigins },
|
||||
{ key: 'commercial_arrival', label: 'ARRIVAL', options: uniqueDestinations },
|
||||
{
|
||||
key: 'commercial_airline',
|
||||
label: 'AIRLINE',
|
||||
options: uniqueAirlines,
|
||||
optionLabels: airlineLabels,
|
||||
},
|
||||
private: {
|
||||
title: 'PRIVATE / JETS',
|
||||
icon: <Plane size={13} className="text-orange-400" />,
|
||||
accentColor: '#FF8C00',
|
||||
accentColorName: 'orange',
|
||||
fields: [
|
||||
{ key: 'private_callsign', label: 'CALLSIGN / REG', options: uniquePrivateCallsigns },
|
||||
{ key: 'private_aircraft_type', label: 'AIRCRAFT TYPE', options: uniquePrivateAircraftTypes },
|
||||
]
|
||||
],
|
||||
},
|
||||
private: {
|
||||
title: 'PRIVATE / JETS',
|
||||
icon: <Plane size={13} className="text-orange-400" />,
|
||||
accentColor: '#FF8C00',
|
||||
accentColorName: 'orange',
|
||||
fields: [
|
||||
{ key: 'private_callsign', label: 'CALLSIGN / REG', options: uniquePrivateCallsigns },
|
||||
{
|
||||
key: 'private_aircraft_type',
|
||||
label: 'AIRCRAFT TYPE',
|
||||
options: uniquePrivateAircraftTypes,
|
||||
},
|
||||
military: {
|
||||
title: 'MILITARY',
|
||||
icon: <Shield size={13} className="text-yellow-400" />,
|
||||
accentColor: '#EAB308',
|
||||
accentColorName: 'yellow',
|
||||
fields: [
|
||||
{ key: 'military_country', label: 'COUNTRY / REG', options: uniqueMilCountries },
|
||||
{ key: 'military_aircraft_type', label: 'AIRCRAFT TYPE', options: uniqueMilAircraftTypes },
|
||||
]
|
||||
},
|
||||
tracked: {
|
||||
title: 'TRACKED AIRCRAFT',
|
||||
icon: <Star size={13} className="text-pink-400" />,
|
||||
accentColor: '#EC4899',
|
||||
accentColorName: 'pink',
|
||||
fields: [
|
||||
{ key: 'tracked_category', label: 'CATEGORY', options: uniqueTrackedCategories },
|
||||
{ key: 'tracked_owner', label: 'OPERATOR / ENTITY', options: uniqueTrackedOperators },
|
||||
]
|
||||
},
|
||||
ships: {
|
||||
title: 'MARITIME VESSELS',
|
||||
icon: <Ship size={13} className="text-blue-400" />,
|
||||
accentColor: '#3B82F6',
|
||||
accentColorName: 'blue',
|
||||
fields: [
|
||||
{ key: 'ship_name', label: 'VESSEL NAME', options: uniqueShipNames },
|
||||
{ key: 'ship_type', label: 'VESSEL TYPE', options: uniqueVesselTypes },
|
||||
]
|
||||
}
|
||||
};
|
||||
],
|
||||
},
|
||||
military: {
|
||||
title: 'MILITARY',
|
||||
icon: <Shield size={13} className="text-yellow-400" />,
|
||||
accentColor: '#EAB308',
|
||||
accentColorName: 'yellow',
|
||||
fields: [
|
||||
{ key: 'military_country', label: 'COUNTRY / REG', options: uniqueMilCountries },
|
||||
{ key: 'military_aircraft_type', label: 'AIRCRAFT TYPE', options: uniqueMilAircraftTypes },
|
||||
],
|
||||
},
|
||||
tracked: {
|
||||
title: 'TRACKED AIRCRAFT',
|
||||
icon: <Star size={13} className="text-pink-400" />,
|
||||
accentColor: '#EC4899',
|
||||
accentColorName: 'pink',
|
||||
fields: [
|
||||
{ key: 'tracked_category', label: 'CATEGORY', options: uniqueTrackedCategories },
|
||||
{ key: 'tracked_owner', label: 'OPERATOR / ENTITY', options: uniqueTrackedOperators },
|
||||
],
|
||||
},
|
||||
ships: {
|
||||
title: 'MARITIME VESSELS',
|
||||
icon: <Ship size={13} className="text-blue-400" />,
|
||||
accentColor: '#3B82F6',
|
||||
accentColorName: 'blue',
|
||||
fields: [
|
||||
{ key: 'ship_name', label: 'VESSEL NAME', options: uniqueShipNames },
|
||||
{ key: 'ship_type', label: 'VESSEL TYPE', options: uniqueVesselTypes },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const clearAll = () => setActiveFilters({});
|
||||
const clearAll = () => setActiveFilters({});
|
||||
|
||||
const activeCount = Object.values(activeFilters).reduce((acc, arr) => acc + arr.length, 0);
|
||||
const activeCount = Object.values(activeFilters).reduce((acc, arr) => acc + arr.length, 0);
|
||||
|
||||
const getCountForCategory = (category: string) => {
|
||||
const config = modalConfigs[category];
|
||||
if (!config) return 0;
|
||||
return config.fields.reduce((acc, f) => acc + (activeFilters[f.key]?.length || 0), 0);
|
||||
};
|
||||
const getCountForCategory = (category: string) => {
|
||||
const config = modalConfigs[category];
|
||||
if (!config) return 0;
|
||||
return config.fields.reduce((acc, f) => acc + (activeFilters[f.key]?.length || 0), 0);
|
||||
};
|
||||
|
||||
const handleModalApply = (categoryKey: string, modalFilters: Record<string, string[]>) => {
|
||||
const config = modalConfigs[categoryKey];
|
||||
const next = { ...activeFilters };
|
||||
for (const field of config.fields) {
|
||||
delete next[field.key];
|
||||
}
|
||||
for (const [key, values] of Object.entries(modalFilters)) {
|
||||
if (values.length > 0) next[key] = values;
|
||||
}
|
||||
setActiveFilters(next);
|
||||
};
|
||||
const handleModalApply = (categoryKey: string, modalFilters: Record<string, string[]>) => {
|
||||
const config = modalConfigs[categoryKey];
|
||||
const next = { ...activeFilters };
|
||||
for (const field of config.fields) {
|
||||
delete next[field.key];
|
||||
}
|
||||
for (const [key, values] of Object.entries(modalFilters)) {
|
||||
if (values.length > 0) next[key] = values;
|
||||
}
|
||||
setActiveFilters(next);
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{ key: 'commercial', title: 'COMMERCIAL FLIGHTS', icon: <Plane size={11} className="text-cyan-400" />, color: 'cyan' },
|
||||
{ key: 'private', title: 'PRIVATE / JETS', icon: <Plane size={11} className="text-orange-400" />, color: 'orange' },
|
||||
{ key: 'military', title: 'MILITARY', icon: <Shield size={11} className="text-yellow-400" />, color: 'yellow' },
|
||||
{ key: 'tracked', title: 'TRACKED AIRCRAFT', icon: <Star size={11} className="text-pink-400" />, color: 'pink' },
|
||||
{ key: 'ships', title: 'MARITIME VESSELS', icon: <Ship size={11} className="text-blue-400" />, color: 'blue' },
|
||||
];
|
||||
const sections = [
|
||||
{
|
||||
key: 'commercial',
|
||||
title: 'COMMERCIAL FLIGHTS',
|
||||
icon: <Plane size={11} className="text-cyan-400" />,
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
key: 'private',
|
||||
title: 'PRIVATE / JETS',
|
||||
icon: <Plane size={11} className="text-orange-400" />,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
key: 'military',
|
||||
title: 'MILITARY',
|
||||
icon: <Shield size={11} className="text-yellow-400" />,
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
key: 'tracked',
|
||||
title: 'TRACKED AIRCRAFT',
|
||||
icon: <Star size={11} className="text-pink-400" />,
|
||||
color: 'pink',
|
||||
},
|
||||
{
|
||||
key: 'ships',
|
||||
title: 'MARITIME VESSELS',
|
||||
icon: <Ship size={11} className="text-blue-400" />,
|
||||
color: 'blue',
|
||||
},
|
||||
];
|
||||
|
||||
const borderColors: Record<string, string> = {
|
||||
cyan: 'border-cyan-500/20 hover:border-cyan-500/40',
|
||||
orange: 'border-orange-500/20 hover:border-orange-500/40',
|
||||
yellow: 'border-yellow-500/20 hover:border-yellow-500/40',
|
||||
pink: 'border-pink-500/20 hover:border-pink-500/40',
|
||||
blue: 'border-blue-500/20 hover:border-blue-500/40',
|
||||
};
|
||||
const textColors: Record<string, string> = {
|
||||
cyan: 'text-cyan-400',
|
||||
orange: 'text-orange-400',
|
||||
yellow: 'text-yellow-400',
|
||||
pink: 'text-pink-400',
|
||||
blue: 'text-blue-400',
|
||||
};
|
||||
const bgColors: Record<string, string> = {
|
||||
cyan: 'bg-cyan-500/10',
|
||||
orange: 'bg-orange-500/10',
|
||||
yellow: 'bg-yellow-500/10',
|
||||
pink: 'bg-pink-500/10',
|
||||
blue: 'bg-blue-500/10',
|
||||
};
|
||||
const borderColors: Record<string, string> = {
|
||||
cyan: 'border-cyan-500/20 hover:border-cyan-500/40',
|
||||
orange: 'border-orange-500/20 hover:border-orange-500/40',
|
||||
yellow: 'border-yellow-500/20 hover:border-yellow-500/40',
|
||||
pink: 'border-pink-500/20 hover:border-pink-500/40',
|
||||
blue: 'border-blue-500/20 hover:border-blue-500/40',
|
||||
};
|
||||
const textColors: Record<string, string> = {
|
||||
cyan: 'text-cyan-400',
|
||||
orange: 'text-orange-400',
|
||||
yellow: 'text-yellow-400',
|
||||
pink: 'text-pink-400',
|
||||
blue: 'text-blue-400',
|
||||
};
|
||||
const bgColors: Record<string, string> = {
|
||||
cyan: 'bg-cyan-500/10',
|
||||
orange: 'bg-orange-500/10',
|
||||
yellow: 'bg-yellow-500/10',
|
||||
pink: 'bg-pink-500/10',
|
||||
blue: 'bg-blue-500/10',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ y: -30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="w-full bg-[var(--bg-primary)]/40 backdrop-blur-sm border border-[var(--border-primary)] z-10 flex flex-col font-mono text-sm pointer-events-auto flex-shrink-0"
|
||||
>
|
||||
{/* Header Toggle */}
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={12} className="text-cyan-500" />
|
||||
<span className="text-[12px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
DATA FILTERS
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="text-[10px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-sm font-mono">
|
||||
{activeCount} ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ y: -30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
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"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-y-auto styled-scrollbar flex flex-col gap-2 p-3 pt-2 max-h-[400px]"
|
||||
>
|
||||
{/* Header Toggle */}
|
||||
<div
|
||||
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)}
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-[10px] text-red-400 hover:text-red-300 font-mono tracking-widest self-end mb-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={12} className="text-cyan-500" />
|
||||
<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>
|
||||
CLEAR ALL FILTERS
|
||||
</button>
|
||||
)}
|
||||
|
||||
{sections.map((section) => {
|
||||
const count = getCountForCategory(section.key);
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
className={`border 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-[11px] text-[var(--text-secondary)] font-mono 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-[var(--text-muted)] group-hover:text-[var(--text-secondary)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <SlidersHorizontal size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-y-auto styled-scrollbar flex flex-col gap-2 p-3 pt-2 max-h-[400px]"
|
||||
>
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-[9px] text-red-400 hover:text-red-300 tracking-widest self-end mb-1"
|
||||
>
|
||||
CLEAR ALL FILTERS
|
||||
</button>
|
||||
)}
|
||||
|
||||
{sections.map(section => {
|
||||
const count = getCountForCategory(section.key);
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
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-[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-[var(--text-muted)] group-hover:text-[var(--text-secondary)] transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Render active modal */}
|
||||
<AnimatePresence>
|
||||
{openModal && modalConfigs[openModal] && (
|
||||
<AdvancedFilterModal
|
||||
key={openModal}
|
||||
title={modalConfigs[openModal].title}
|
||||
icon={modalConfigs[openModal].icon}
|
||||
accentColor={modalConfigs[openModal].accentColor}
|
||||
accentColorName={modalConfigs[openModal].accentColorName}
|
||||
fields={modalConfigs[openModal].fields}
|
||||
activeFilters={activeFilters}
|
||||
onApply={(filters) => handleModalApply(openModal, filters)}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
{/* Render active modal */}
|
||||
<AnimatePresence>
|
||||
{openModal && modalConfigs[openModal] && (
|
||||
<AdvancedFilterModal
|
||||
key={openModal}
|
||||
title={modalConfigs[openModal].title}
|
||||
icon={modalConfigs[openModal].icon}
|
||||
accentColor={modalConfigs[openModal].accentColor}
|
||||
accentColorName={modalConfigs[openModal].accentColorName}
|
||||
fields={modalConfigs[openModal].fields}
|
||||
activeFilters={activeFilters}
|
||||
onApply={(filters) => handleModalApply(openModal, filters)}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FilterPanel;
|
||||
|
||||
@@ -1,244 +1,264 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { Search, Crosshair, Plane, Shield, Star, Ship, X, Database } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Search, Crosshair, Plane, Shield, Star, Ship, X, Database } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { trackedOperators } from '../lib/trackedData';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
|
||||
interface FindLocateBarProps {
|
||||
data: any;
|
||||
onLocate: (lat: number, lng: number, entityId: string, entityType: string) => void;
|
||||
onFilter?: (filterType: string, filterValue: string) => void;
|
||||
onLocate: (lat: number, lng: number, entityId: string, entityType: string) => void;
|
||||
onFilter?: (filterType: string, filterValue: string) => void;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
label: string;
|
||||
sublabel: string;
|
||||
category: string;
|
||||
categoryColor: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
entityType: string;
|
||||
id: string;
|
||||
label: string;
|
||||
sublabel: string;
|
||||
category: string;
|
||||
categoryColor: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
entityType: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBarProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: FindLocateBarProps) {
|
||||
const data = useDataKeys(['commercial_flights', 'private_flights', 'private_jets', 'military_flights', 'tracked_flights', 'ships'] as const);
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
// Build searchable index from all data
|
||||
const allEntities = useMemo(() => {
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// Commercial flights
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
results.push({
|
||||
id: `flight-${uid}`,
|
||||
label: f.callsign || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · ${f.airline_code || 'Commercial'}`,
|
||||
category: "COMMERCIAL",
|
||||
categoryColor: "text-cyan-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "flight",
|
||||
});
|
||||
}
|
||||
|
||||
// Private flights
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
const type = f.type === 'private_jet' ? 'private_jet' : 'private_flight';
|
||||
results.push({
|
||||
id: `${type === 'private_jet' ? 'private-jet' : 'private-flight'}-${uid}`,
|
||||
label: f.callsign || f.registration || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · Private`,
|
||||
category: "PRIVATE",
|
||||
categoryColor: "text-orange-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: type,
|
||||
});
|
||||
}
|
||||
|
||||
// Military flights
|
||||
for (const f of data?.military_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
results.push({
|
||||
id: `mil-flight-${uid}`,
|
||||
label: f.callsign || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · ${f.military_type || 'Military'}`,
|
||||
category: "MILITARY",
|
||||
categoryColor: "text-yellow-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "military_flight",
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
sublabel: `${category} · ${type} (${f.registration || uid})`,
|
||||
category: "TRACKED",
|
||||
categoryColor: "text-pink-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "tracked_flight",
|
||||
_extra: extras,
|
||||
} as any);
|
||||
}
|
||||
|
||||
// Ships
|
||||
for (const s of data?.ships || []) {
|
||||
results.push({
|
||||
id: `ship-${s.mmsi || s.name || ''}`,
|
||||
label: s.name || "UNKNOWN",
|
||||
sublabel: `${s.type || 'Vessel'} · ${s.destination || 'Unknown dest'}`,
|
||||
category: "MARITIME",
|
||||
categoryColor: "text-blue-400",
|
||||
lat: s.lat,
|
||||
lng: s.lng,
|
||||
entityType: "ship",
|
||||
});
|
||||
}
|
||||
|
||||
// Database Records - Tracked Operators
|
||||
for (const op of trackedOperators) {
|
||||
results.push({
|
||||
id: `tracked-db-${op}`,
|
||||
label: op,
|
||||
sublabel: `Database Record · Operator`,
|
||||
category: "DATABASE",
|
||||
categoryColor: "text-purple-400",
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
entityType: "database_operator",
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [data]);
|
||||
|
||||
// Filter results based on query
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.toLowerCase();
|
||||
return allEntities
|
||||
.filter(e => {
|
||||
const searchable = `${e.label} ${e.sublabel} ${e.id} ${(e as any)._extra || ''}`.toLowerCase();
|
||||
return searchable.includes(q);
|
||||
})
|
||||
.slice(0, 12);
|
||||
}, [query, allEntities]);
|
||||
|
||||
const handleSelect = (result: SearchResult) => {
|
||||
if (result.entityType === "database_operator") {
|
||||
if (onFilter) onFilter("tracked_owner", result.label);
|
||||
} else {
|
||||
onLocate(result.lat, result.lng, result.id, result.entityType);
|
||||
}
|
||||
setQuery("");
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const categoryIcons: Record<string, React.ReactNode> = {
|
||||
COMMERCIAL: <Plane size={10} className="text-cyan-400" />,
|
||||
PRIVATE: <Plane size={10} className="text-orange-400" />,
|
||||
MILITARY: <Shield size={10} className="text-yellow-400" />,
|
||||
TRACKED: <Star size={10} className="text-pink-400" />,
|
||||
MARITIME: <Ship size={10} className="text-blue-400" />,
|
||||
DATABASE: <Database size={10} className="text-purple-400" />,
|
||||
};
|
||||
// Build searchable index from all data
|
||||
const allEntities = useMemo(() => {
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full pointer-events-auto">
|
||||
<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, 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);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
/>
|
||||
{query && (
|
||||
<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-[var(--text-muted)] flex-shrink-0" />
|
||||
// Commercial flights
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
results.push({
|
||||
id: `flight-${uid}`,
|
||||
label: f.callsign || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · ${f.airline_code || 'Commercial'}`,
|
||||
category: 'COMMERCIAL',
|
||||
categoryColor: 'text-cyan-400',
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: 'flight',
|
||||
});
|
||||
}
|
||||
|
||||
// Private flights
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
const type = f.type === 'private_jet' ? 'private_jet' : 'private_flight';
|
||||
results.push({
|
||||
id: `${type === 'private_jet' ? 'private-jet' : 'private-flight'}-${uid}`,
|
||||
label: f.callsign || f.registration || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · Private`,
|
||||
category: 'PRIVATE',
|
||||
categoryColor: 'text-orange-400',
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: type,
|
||||
});
|
||||
}
|
||||
|
||||
// Military flights
|
||||
for (const f of data?.military_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
results.push({
|
||||
id: `mil-flight-${uid}`,
|
||||
label: f.callsign || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · ${f.military_type || 'Military'}`,
|
||||
category: 'MILITARY',
|
||||
categoryColor: 'text-yellow-400',
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: 'military_flight',
|
||||
});
|
||||
}
|
||||
|
||||
// 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_operator, f.alert_tags, f.owner, f.name, f.callsign, f.registration].filter(Boolean).join(' ');
|
||||
results.push({
|
||||
id: `tracked-${uid}`,
|
||||
label: operator,
|
||||
sublabel: `${category} · ${type} (${f.registration || uid})`,
|
||||
category: 'TRACKED',
|
||||
categoryColor: 'text-pink-400',
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: 'tracked_flight',
|
||||
extra: extras,
|
||||
});
|
||||
}
|
||||
|
||||
// Ships
|
||||
for (const s of data?.ships || []) {
|
||||
results.push({
|
||||
id: `ship-${s.mmsi || s.name || ''}`,
|
||||
label: s.name || 'UNKNOWN',
|
||||
sublabel: `${s.type || 'Vessel'} · ${s.destination || 'Unknown dest'}`,
|
||||
category: 'MARITIME',
|
||||
categoryColor: 'text-blue-400',
|
||||
lat: s.lat,
|
||||
lng: s.lng,
|
||||
entityType: 'ship',
|
||||
});
|
||||
}
|
||||
|
||||
// Database Records - Tracked Operators
|
||||
for (const op of trackedOperators) {
|
||||
results.push({
|
||||
id: `tracked-db-${op}`,
|
||||
label: op,
|
||||
sublabel: `Database Record · Operator`,
|
||||
category: 'DATABASE',
|
||||
categoryColor: 'text-purple-400',
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
entityType: 'database_operator',
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [data]);
|
||||
|
||||
// Filter results based on query
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.toLowerCase();
|
||||
return allEntities
|
||||
.filter((e) => {
|
||||
const searchable = `${e.label} ${e.sublabel} ${e.id} ${e.extra || ''}`.toLowerCase();
|
||||
return searchable.includes(q);
|
||||
})
|
||||
.slice(0, 12);
|
||||
}, [query, allEntities]);
|
||||
|
||||
const handleSelect = (result: SearchResult) => {
|
||||
if (result.entityType === 'database_operator') {
|
||||
if (onFilter) onFilter('tracked_owner', result.label);
|
||||
} else {
|
||||
onLocate(result.lat, result.lng, result.id, result.entityType);
|
||||
}
|
||||
setQuery('');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, React.ReactNode> = {
|
||||
COMMERCIAL: <Plane size={10} className="text-cyan-400" />,
|
||||
PRIVATE: <Plane size={10} className="text-orange-400" />,
|
||||
MILITARY: <Shield size={10} className="text-yellow-400" />,
|
||||
TRACKED: <Star size={10} className="text-pink-400" />,
|
||||
MARITIME: <Ship size={10} className="text-blue-400" />,
|
||||
DATABASE: <Database size={10} className="text-purple-400" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full pointer-events-auto">
|
||||
<div className="flex items-center gap-2 bg-[var(--bg-primary)]/40 backdrop-blur-sm border border-[var(--border-primary)] px-3 py-2 focus-within:border-cyan-500/40 transition-colors">
|
||||
<Search size={12} className="text-slate-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
name="sb-locate-search"
|
||||
autoComplete="off"
|
||||
placeholder="Search aircraft, person or vessel..."
|
||||
className="flex-1 bg-transparent text-[12px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-slate-500"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
/>
|
||||
{query && (
|
||||
<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-[var(--text-muted)] flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && filtered.length > 0 && (
|
||||
<motion.div
|
||||
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-[var(--bg-secondary)]/90 backdrop-blur-sm border border-[var(--border-primary)] 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-[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 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-[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}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
{isOpen && query.trim() && filtered.length === 0 && (
|
||||
<motion.div
|
||||
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-[var(--bg-secondary)]/90 backdrop-blur-sm border border-[var(--border-primary)] z-50 p-4 text-center"
|
||||
>
|
||||
<div className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
NO MATCHING ASSETS
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && filtered.length > 0 && (
|
||||
<motion.div
|
||||
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-[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-[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-[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-[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}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
{isOpen && query.trim() && filtered.length === 0 && (
|
||||
<motion.div
|
||||
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-[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-[var(--text-muted)] font-mono tracking-widest">NO MATCHING ASSETS</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default FindLocateBar;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, AlertTriangle, ChevronUp } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
|
||||
export default function GlobalTicker() {
|
||||
const { stocks, financial_source } = useDataKeys(['stocks', 'financial_source'] as const);
|
||||
const entries = Object.entries(stocks || {});
|
||||
const fallback = financial_source === 'yfinance';
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
// Render a single ticker item
|
||||
const renderItem = ([ticker, info]: [string, any], index: number) => {
|
||||
// Determine color based on price action
|
||||
let colorClass = 'text-white';
|
||||
if (info.change_percent > 0) colorClass = 'text-green-400';
|
||||
if (info.change_percent < 0) colorClass = 'text-red-400';
|
||||
|
||||
const isCryptoHighlight = ticker === 'BTC' || ticker === 'ETH';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${ticker}-${index}`}
|
||||
className={`flex items-center gap-3 shrink-0 mx-5 font-mono ${isCryptoHighlight ? 'bg-cyan-950/30 px-3 py-1 rounded-sm border border-cyan-500/20 shadow-[0_0_10px_rgba(6,182,212,0.15)]' : ''}`}
|
||||
>
|
||||
<span className={`font-bold text-[11px] uppercase tracking-widest ${isCryptoHighlight ? 'text-cyan-400' : 'text-cyan-300'}`}>
|
||||
{isCryptoHighlight && <span className="mr-1.5 text-cyan-500">★</span>}
|
||||
{ticker}
|
||||
</span>
|
||||
<span className={`font-bold text-[12px] ${isCryptoHighlight ? 'text-white' : 'text-[var(--text-primary)]'}`}>
|
||||
${(info.price ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className={`flex items-center gap-0.5 text-[10px] font-bold ${colorClass}`}>
|
||||
{info.up ? <ArrowUpRight size={12} /> : info.change_percent < 0 ? <ArrowDownRight size={12} /> : <span className="w-3"></span>}
|
||||
{Math.abs(info.change_percent ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-7 bg-black/95 border-t border-[var(--border-primary)] shadow-[0_-5px_15px_rgba(0,0,0,0.6)] z-[8000] flex items-center overflow-hidden pointer-events-auto backdrop-blur-xl"
|
||||
>
|
||||
|
||||
{fallback && (
|
||||
<div className="absolute right-0 top-0 bottom-0 bg-gradient-to-l from-red-950/90 via-black/80 to-transparent w-[450px] z-10 flex items-center justify-end px-4 pointer-events-none">
|
||||
<div className="flex items-center gap-2 text-red-400 bg-red-950/50 px-2 pl-3 py-0.5 border border-red-500/30 rounded shadow-[0_0_10px_rgba(239,68,68,0.2)]">
|
||||
<AlertTriangle size={10} className="animate-pulse" />
|
||||
<span className="text-[8px] font-mono font-bold tracking-widest uppercase shadow-black drop-shadow-md">
|
||||
SYS WARN: FINNHUB API KEY MISSING — YAHOO FALLBACK ACTIVE (LIMITED)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The scrolling container */}
|
||||
<motion.div
|
||||
className="flex items-center whitespace-nowrap will-change-transform pl-4"
|
||||
animate={{ x: ["0%", "-50%"] }}
|
||||
transition={{ ease: "linear", duration: 60, repeat: Infinity }}
|
||||
>
|
||||
{/* Render the list twice for seamless infinite scrolling */}
|
||||
<div className="flex items-center">
|
||||
{entries.map((item, i) => renderItem(item, i))}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{entries.map((item, i) => renderItem(item, i + entries.length))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
export interface HlsVideoHandle {
|
||||
play(): void;
|
||||
pause(): void;
|
||||
get paused(): boolean;
|
||||
}
|
||||
|
||||
const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; onError?: () => void }>(
|
||||
({ url, className, onError }, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: () => videoRef.current?.play(),
|
||||
pause: () => videoRef.current?.pause(),
|
||||
get paused() {
|
||||
return videoRef.current?.paused ?? true;
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !url) return;
|
||||
|
||||
let hlsInstance: { destroy(): void } | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const { default: Hls } = await import('hls.js');
|
||||
if (cancelled) return;
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({ enableWorker: false, lowLatencyMode: true });
|
||||
hls.on(Hls.Events.ERROR, (_e: unknown, data: { fatal?: boolean }) => {
|
||||
if (data.fatal) onError?.();
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hlsInstance = hls;
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url;
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
hlsInstance?.destroy();
|
||||
};
|
||||
}, [url, onError]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
onError={() => onError?.()}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HlsVideo.displayName = 'HlsVideo';
|
||||
export default HlsVideo;
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ChevronLeft, Vote } from 'lucide-react';
|
||||
|
||||
export default function BallotView({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<Vote className="mr-2 text-cyan-400" />
|
||||
OPEN BALLOT
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Governance is not live in this shell yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 pb-4">
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-6 md:p-8">
|
||||
<div className="border border-cyan-900/40 bg-cyan-950/10 px-6 py-10 text-center">
|
||||
<div className="text-3xl md:text-5xl font-bold tracking-[0.34em] text-cyan-300">
|
||||
DEMOCRACY FOR ALL SOON
|
||||
</div>
|
||||
<p className="mt-5 text-sm text-gray-300 max-w-3xl mx-auto leading-relaxed">
|
||||
There are no live referendums, petitions, or tallies being advertised here right now.
|
||||
This testnet shell should not imply outcomes, fake counts, or policy promises that do
|
||||
not exist yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div className="border border-gray-800 bg-black/20 p-4">
|
||||
<div className="text-[10px] text-cyan-400 uppercase tracking-[0.22em]">
|
||||
Principle
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-300 leading-relaxed">
|
||||
Governance should be real, verifiable, and community-shaped before it appears in the shell.
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-800 bg-black/20 p-4">
|
||||
<div className="text-[10px] text-cyan-400 uppercase tracking-[0.22em]">
|
||||
Current stance
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-300 leading-relaxed">
|
||||
No timeline, no fake proposals, and no synthetic vote counts are being presented in this build.
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-800 bg-black/20 p-4">
|
||||
<div className="text-[10px] text-cyan-400 uppercase tracking-[0.22em]">
|
||||
Testnet focus
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-300 leading-relaxed">
|
||||
The priority right now is privacy posture, working nodes, real gates, and stable communication.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ChevronLeft, ArrowRightLeft, TrendingUp, TrendingDown, Activity, Wallet, ArrowDownToLine, ArrowUpFromLine, Copy, X } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import type { DashboardData, StockTicker } from '@/types/dashboard';
|
||||
|
||||
interface ExchangeViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'stocks'>;
|
||||
const DATA_KEYS = ['stocks'] as const;
|
||||
|
||||
// Symbols we want to show as crypto trading pairs
|
||||
const CRYPTO_SYMBOLS = ['BTC', 'ETH', 'SOL', 'XRP', 'DOGE'];
|
||||
const CRYPTO_NAMES: Record<string, string> = {
|
||||
BTC: 'Bitcoin', ETH: 'Ethereum', SOL: 'Solana', XRP: 'Ripple', DOGE: 'Dogecoin',
|
||||
ZEC: 'Zcash', XMR: 'Monero', ADA: 'Cardano', DOT: 'Polkadot', AVAX: 'Avalanche',
|
||||
};
|
||||
|
||||
const FALLBACK_PAIRS = [
|
||||
{ symbol: 'BTC', name: 'Bitcoin', price: '—', change: '—', up: true },
|
||||
{ symbol: 'ETH', name: 'Ethereum', price: '—', change: '—', up: true },
|
||||
{ symbol: 'SOL', name: 'Solana', price: '—', change: '—', up: true },
|
||||
];
|
||||
|
||||
const MOCK_BALANCES = [
|
||||
{ symbol: 'CREDITS', name: 'Credits', balance: '12,540.00', value: '12,540.00' },
|
||||
{ symbol: 'BTC', name: 'Bitcoin', balance: '0.045', value: '56,025.00' },
|
||||
{ symbol: 'ETH', name: 'Ethereum', balance: '1.2', value: '101,040.60' },
|
||||
{ symbol: 'SOL', name: 'Solana', balance: '45.0', value: '184,511.25' },
|
||||
{ symbol: 'ZEC', name: 'Zcash', balance: '0.00', value: '0.00' },
|
||||
{ symbol: 'XMR', name: 'Monero', balance: '2.5', value: '72,251.87' },
|
||||
];
|
||||
|
||||
const ORDER_BOOK_BIDS = [
|
||||
{ price: '1,244,900.00', amount: '0.05', total: '62,245.00' },
|
||||
{ price: '1,244,850.00', amount: '0.12', total: '149,382.00' },
|
||||
{ price: '1,244,800.00', amount: '0.80', total: '995,840.00' },
|
||||
];
|
||||
|
||||
const ORDER_BOOK_ASKS = [
|
||||
{ price: '1,245,100.00', amount: '0.02', total: '24,902.00' },
|
||||
{ price: '1,245,150.00', amount: '0.15', total: '186,772.50' },
|
||||
{ price: '1,245,200.00', amount: '1.50', total: '1,867,800.00' },
|
||||
];
|
||||
|
||||
export default function ExchangeView({ onBack }: ExchangeViewProps) {
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
const stocks = data?.stocks;
|
||||
|
||||
// Build live trading pairs from real stock data
|
||||
const PAIRS = useMemo(() => {
|
||||
if (!stocks) return FALLBACK_PAIRS;
|
||||
const entries = Object.entries(stocks as Record<string, StockTicker>)
|
||||
.filter(([k]) => !['last_updated', 'source'].includes(k));
|
||||
// Try crypto symbols first, then fill with whatever's available
|
||||
const pairs: { symbol: string; name: string; price: string; change: string; up: boolean }[] = [];
|
||||
for (const sym of CRYPTO_SYMBOLS) {
|
||||
const match = entries.find(([k]) => k.toUpperCase() === sym);
|
||||
if (match) {
|
||||
const [, val] = match;
|
||||
if (val && val.price != null) {
|
||||
pairs.push({
|
||||
symbol: sym,
|
||||
name: CRYPTO_NAMES[sym] || sym,
|
||||
price: val.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||||
change: `${val.change_percent >= 0 ? '+' : ''}${val.change_percent.toFixed(1)}%`,
|
||||
up: val.change_percent >= 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we didn't find enough crypto, add other stock tickers
|
||||
if (pairs.length < 3) {
|
||||
for (const [k, val] of entries) {
|
||||
if (pairs.some(p => p.symbol === k.toUpperCase())) continue;
|
||||
if (val && val.price != null) {
|
||||
pairs.push({
|
||||
symbol: k.toUpperCase(),
|
||||
name: CRYPTO_NAMES[k.toUpperCase()] || k.toUpperCase(),
|
||||
price: val.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||||
change: `${val.change_percent >= 0 ? '+' : ''}${val.change_percent.toFixed(1)}%`,
|
||||
up: val.change_percent >= 0,
|
||||
});
|
||||
if (pairs.length >= 8) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs.length > 0 ? pairs : FALLBACK_PAIRS;
|
||||
}, [stocks]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'trade' | 'funds'>('trade');
|
||||
const [selectedPair, setSelectedPair] = useState(PAIRS[0]);
|
||||
const [orderType, setOrderType] = useState<'BUY' | 'SELL'>('BUY');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [price, setPrice] = useState(selectedPair.price.replace(/,/g, ''));
|
||||
|
||||
const [depositAsset, setDepositAsset] = useState<typeof MOCK_BALANCES[0] | null>(null);
|
||||
const [withdrawAsset, setWithdrawAsset] = useState<typeof MOCK_BALANCES[0] | null>(null);
|
||||
const [withdrawAmount, setWithdrawAmount] = useState('');
|
||||
|
||||
const generateMockAddress = (symbol: string) => {
|
||||
const prefix = symbol === 'BTC' ? 'bc1q' : symbol === 'ETH' ? '0x' : symbol === 'SOL' ? '' : 't1';
|
||||
const randomHex = Array.from({length: 32}, () => Math.floor(Math.random()*16).toString(16)).join('');
|
||||
return `${prefix}${randomHex}`;
|
||||
};
|
||||
|
||||
const getNetworkFee = (symbol: string) => {
|
||||
switch(symbol) {
|
||||
case 'BTC': return 0.00015;
|
||||
case 'ETH': return 0.004;
|
||||
case 'SOL': return 0.005;
|
||||
case 'ZEC': return 0.001;
|
||||
case 'XMR': return 0.002;
|
||||
case 'CREDITS': return 5.00;
|
||||
default: return 0.01;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdrawClick = (asset: typeof MOCK_BALANCES[0]) => {
|
||||
setWithdrawAsset(asset);
|
||||
setWithdrawAmount('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50 mb-4"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<ArrowRightLeft className="mr-2 text-cyan-400" />
|
||||
DECENTRALIZED EXCHANGE
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Trade crypto assets against Credits. Zero KYC. Zero logs.</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-2 mb-4 shrink-0 border-b border-gray-800 pb-2 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('trade')}
|
||||
className={`flex items-center px-4 py-2 uppercase text-xs tracking-widest transition-colors whitespace-nowrap ${activeTab === 'trade' ? 'bg-gray-800/50 text-gray-300 border-b-2 border-cyan-400' : 'text-gray-500 hover:text-gray-400'}`}
|
||||
>
|
||||
<ArrowRightLeft size={14} className="mr-2" /> TRADE
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('funds')}
|
||||
className={`flex items-center px-4 py-2 uppercase text-xs tracking-widest transition-colors whitespace-nowrap ${activeTab === 'funds' ? 'bg-gray-800/50 text-gray-300 border-b-2 border-cyan-400' : 'text-gray-500 hover:text-gray-400'}`}
|
||||
>
|
||||
<Wallet size={14} className="mr-2" /> FUNDS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 flex flex-col md:flex-row gap-4 pb-4">
|
||||
|
||||
{/* TRADE TAB */}
|
||||
{activeTab === 'trade' && (
|
||||
<>
|
||||
{/* Left Column: Pairs & Chart */}
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
{/* Pairs List */}
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<Activity size={16} className="mr-2" /> TRADING PAIRS (vs CREDITS)
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{PAIRS.map(pair => (
|
||||
<div
|
||||
key={pair.symbol}
|
||||
onClick={() => { setSelectedPair(pair); setPrice(pair.price.replace(/,/g, '')); }}
|
||||
className={`flex justify-between items-center p-2 cursor-pointer transition-colors border ${selectedPair.symbol === pair.symbol ? 'border-cyan-400 bg-cyan-900/20' : 'border-gray-800 bg-[#0a0a0a] hover:border-gray-700'}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-gray-300 w-12">{pair.symbol}</span>
|
||||
<span className="text-gray-500 text-xs hidden sm:inline">{pair.name}</span>
|
||||
</div>
|
||||
<div className="text-right flex items-center gap-4">
|
||||
<span className="font-mono text-gray-400">{pair.price}</span>
|
||||
<span className={`text-xs flex items-center w-16 justify-end ${pair.up ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{pair.up ? <TrendingUp size={12} className="mr-1" /> : <TrendingDown size={12} className="mr-1" />}
|
||||
{pair.change}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simple Chart Area */}
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4 flex-1 flex flex-col">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex justify-between items-center">
|
||||
<span>{selectedPair.symbol}/CREDITS CHART</span>
|
||||
<span className="text-xs text-gray-500">1H | 4H | 1D | 1W</span>
|
||||
</h2>
|
||||
<div className="flex-1 flex items-end justify-between gap-1 pt-4 h-32">
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const height = 20 + Math.random() * 80;
|
||||
const isUp = Math.random() > 0.5;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-full ${isUp ? 'bg-green-500/50 border-t border-green-400' : 'bg-red-500/50 border-t border-red-400'}`}
|
||||
style={{ height: `${height}%` }}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Order Book & Trade Form */}
|
||||
<div className="w-full md:w-80 flex flex-col gap-4 shrink-0">
|
||||
{/* Trade Form */}
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setOrderType('BUY')}
|
||||
className={`flex-1 py-2 font-bold text-sm border transition-colors ${orderType === 'BUY' ? 'bg-green-900/50 border-green-400 text-green-400' : 'bg-black border-gray-800 text-gray-500 hover:border-gray-700'}`}
|
||||
>
|
||||
BUY {selectedPair.symbol}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOrderType('SELL')}
|
||||
className={`flex-1 py-2 font-bold text-sm border transition-colors ${orderType === 'SELL' ? 'bg-red-900/50 border-red-400 text-red-400' : 'bg-black border-gray-800 text-gray-500 hover:border-gray-700'}`}
|
||||
>
|
||||
SELL {selectedPair.symbol}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest mb-1 block">Price (Credits)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
className="w-full bg-black border border-gray-800 p-2 text-gray-300 font-mono outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest mb-1 block">Amount ({selectedPair.symbol})</label>
|
||||
<input
|
||||
type="text"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full bg-black border border-gray-800 p-2 text-gray-300 font-mono outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-gray-800 flex justify-between items-center">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Total</span>
|
||||
<span className="font-mono text-gray-300 font-bold">
|
||||
{amount && price && !isNaN(Number(amount)) && !isNaN(Number(price))
|
||||
? (Number(amount) * Number(price)).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: '0.00'} CREDITS
|
||||
</span>
|
||||
</div>
|
||||
<button className={`w-full py-3 font-bold uppercase tracking-widest transition-colors ${orderType === 'BUY' ? 'bg-green-600 hover:bg-green-500 text-black' : 'bg-red-600 hover:bg-red-500 text-black'}`}>
|
||||
{orderType} {selectedPair.symbol}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Book */}
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4 flex-1">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2">ORDER BOOK</h2>
|
||||
<div className="flex justify-between text-xs text-gray-500 uppercase tracking-widest mb-2 px-1">
|
||||
<span>Price(CREDITS)</span>
|
||||
<span>Amt({selectedPair.symbol})</span>
|
||||
<span>Total</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-4">
|
||||
{ORDER_BOOK_ASKS.slice().reverse().map((ask, i) => (
|
||||
<div key={i} className="flex justify-between text-xs font-mono px-1 hover:bg-gray-800/50 cursor-pointer">
|
||||
<span className="text-red-400">{ask.price}</span>
|
||||
<span className="text-gray-300">{ask.amount}</span>
|
||||
<span className="text-gray-500">{ask.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="py-2 border-y border-gray-800 text-center font-mono font-bold text-gray-300 mb-4">
|
||||
{selectedPair.price} <span className="text-gray-500 text-xs font-sans">Spread: 100.00</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{ORDER_BOOK_BIDS.map((bid, i) => (
|
||||
<div key={i} className="flex justify-between text-xs font-mono px-1 hover:bg-gray-800/50 cursor-pointer">
|
||||
<span className="text-green-400">{bid.price}</span>
|
||||
<span className="text-gray-300">{bid.amount}</span>
|
||||
<span className="text-gray-500">{bid.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* FUNDS TAB */}
|
||||
{activeTab === 'funds' && (
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<Wallet size={16} className="mr-2" /> ASSET BALANCES
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{MOCK_BALANCES.map(asset => (
|
||||
<div key={asset.symbol} className="flex flex-col sm:flex-row justify-between items-start sm:items-center p-3 border border-gray-800 bg-[#0a0a0a] hover:border-gray-700 transition-colors gap-4">
|
||||
<div className="flex items-center gap-3 w-48">
|
||||
<div className="w-8 h-8 bg-gray-800/50 rounded-full flex items-center justify-center text-gray-300 font-bold">
|
||||
{asset.symbol.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-300">{asset.symbol}</div>
|
||||
<div className="text-xs text-gray-500">{asset.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 text-left sm:text-right">
|
||||
<div className="font-mono text-gray-300">{asset.balance}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">≈ {asset.value} CREDITS</div>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto mt-2 sm:mt-0">
|
||||
<button onClick={() => setDepositAsset(asset)} className="flex-1 sm:flex-none flex items-center justify-center px-3 py-1.5 bg-cyan-900/20 border border-cyan-900/50 text-cyan-400 hover:bg-cyan-900/40 transition-colors text-xs uppercase tracking-widest">
|
||||
<ArrowDownToLine size={14} className="mr-1" /> RECEIVE
|
||||
</button>
|
||||
<button onClick={() => handleWithdrawClick(asset)} className="flex-1 sm:flex-none flex items-center justify-center px-3 py-1.5 bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700 transition-colors text-xs uppercase tracking-widest">
|
||||
<ArrowUpFromLine size={14} className="mr-1" /> SEND
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deposit Modal */}
|
||||
{depositAsset && (
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-[#0a0a0a] border border-cyan-600 p-6 max-w-md w-full shadow-[0_0_30px_rgba(6,182,212,0.15)]">
|
||||
<div className="flex justify-between items-center mb-4 border-b border-gray-800 pb-2">
|
||||
<h2 className="text-cyan-500 text-lg font-bold flex items-center">
|
||||
<ArrowDownToLine className="mr-2" /> RECEIVE {depositAsset.symbol}
|
||||
</h2>
|
||||
<button onClick={() => setDepositAsset(null)} className="text-gray-500 hover:text-white"><X size={20}/></button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<div className="bg-white p-2 mb-4">
|
||||
<div className="w-40 h-40 grid grid-cols-8 grid-rows-8 gap-0.5 bg-white p-1">
|
||||
{Array.from({length: 64}).map((_, i) => (
|
||||
<div key={i} className={Math.random() > 0.4 ? 'bg-black' : 'bg-white'}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-2 text-center">Scan QR code or copy address below</p>
|
||||
<div className="w-full flex items-center bg-black border border-gray-800 p-2">
|
||||
<span className="flex-1 font-mono text-xs text-gray-300 truncate select-all">
|
||||
{generateMockAddress(depositAsset.symbol)}
|
||||
</span>
|
||||
<button className="ml-2 text-cyan-400 hover:text-cyan-300"><Copy size={14} /></button>
|
||||
</div>
|
||||
<p className="text-xs text-red-400 mt-4 text-center">Send ONLY {depositAsset.name} ({depositAsset.symbol}) to this address. Sending any other asset will result in permanent loss.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Withdraw Modal */}
|
||||
{withdrawAsset && (
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-[#0a0a0a] border border-cyan-600 p-6 max-w-md w-full shadow-[0_0_30px_rgba(6,182,212,0.15)]">
|
||||
<div className="flex justify-between items-center mb-4 border-b border-gray-800 pb-2">
|
||||
<h2 className="text-cyan-500 text-lg font-bold flex items-center">
|
||||
<ArrowUpFromLine className="mr-2" /> SEND {withdrawAsset.symbol}
|
||||
</h2>
|
||||
<button onClick={() => setWithdrawAsset(null)} className="text-gray-500 hover:text-white"><X size={20}/></button>
|
||||
</div>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest">Available Balance</label>
|
||||
<span className="text-xs font-mono text-cyan-400">{withdrawAsset.balance} {withdrawAsset.symbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest mb-1 block">Destination Address</label>
|
||||
<input type="text" placeholder={`Enter ${withdrawAsset.symbol} address`} className="w-full bg-black border border-gray-800 p-2 text-gray-300 font-mono outline-none focus:border-cyan-400 text-sm" spellCheck={false} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-widest mb-1 block">Amount</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full bg-black border border-gray-800 p-2 text-gray-300 font-mono outline-none focus:border-cyan-400 text-sm pr-16"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setWithdrawAmount(withdrawAsset.balance)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-cyan-400 hover:text-cyan-300 uppercase tracking-widest font-bold"
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/30 border border-gray-800 p-3 mt-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Network Fee</span>
|
||||
<span className="text-xs font-mono text-gray-400">{getNetworkFee(withdrawAsset.symbol)} {withdrawAsset.symbol}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-t border-gray-800 pt-1 mt-1">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Total Deduction</span>
|
||||
<span className="text-xs font-mono text-white font-bold">
|
||||
{withdrawAmount && !isNaN(Number(withdrawAmount))
|
||||
? (Number(withdrawAmount) + getNetworkFee(withdrawAsset.symbol)).toFixed(withdrawAsset.symbol === 'CREDITS' ? 2 : 6)
|
||||
: '0.00'} {withdrawAsset.symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button className="w-full py-3 bg-cyan-900/50 border border-cyan-500 text-cyan-400 hover:bg-cyan-800 transition-colors font-bold uppercase tracking-widest">
|
||||
CONFIRM SEND
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, RefreshCw, Reply, Search, Send } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { controlPlaneJson } from '@/lib/controlPlane';
|
||||
import { nextSequence } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
decryptWormholeGateMessages,
|
||||
fetchWormholeGateKeyStatus,
|
||||
signMeshEvent,
|
||||
type WormholeGateKeyStatus,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import { gateEnvelopeDisplayText, gateEnvelopeState, isEncryptedGateEnvelope } from '@/mesh/gateEnvelope';
|
||||
import { validateEventPayload } from '@/mesh/meshSchema';
|
||||
|
||||
const GATE_INTROS: Record<string, string> = {
|
||||
infonet:
|
||||
'Welcome to the Infonet general channel. This is the main commons — discuss anything related to the network, ask questions, share intel. Keep it civil.',
|
||||
'general-talk':
|
||||
"Off-topic discussion. Talk about whatever you want — just keep it respectful and don't post anything that'll get the gate burned.",
|
||||
'gathered-intel':
|
||||
'Post verified OSINT findings here. Unverified rumors go in general-talk. Cite your sources or get downvoted into oblivion.',
|
||||
'tracked-planes':
|
||||
"Military and private aviation tracking discussion. Share callsigns, unusual flight patterns, and transponder anomalies you've spotted on the map.",
|
||||
'ukraine-front':
|
||||
'Ukraine conflict monitoring. Frontline updates, satellite imagery analysis, and verified ground reports only. No propaganda.',
|
||||
'iran-front':
|
||||
'Iran and Middle East situational awareness. Missile activity, naval movements, diplomatic developments. Verified sources preferred.',
|
||||
'world-news':
|
||||
"Breaking world events and geopolitical developments. If it's happening right now and it matters, post it here.",
|
||||
'prediction-markets':
|
||||
'Discuss prediction market movements, arbitrage opportunities, and consensus shifts. Polymarket and Kalshi analysis welcome.',
|
||||
finance:
|
||||
'Markets, macro trends, sanctions tracking, and economic intelligence. No financial advice — just signal.',
|
||||
cryptography:
|
||||
'Encryption protocols, zero-knowledge proofs, post-quantum research, and implementation discussion. Show your math.',
|
||||
cryptocurrencies:
|
||||
'Crypto markets, DeFi protocols, chain analysis, and privacy coins. No shilling. No pump groups.',
|
||||
'meet-chat':
|
||||
'Find other sovereigns in your area. Coordinate local meetups, dead drops, or mesh node deployments. Practice good OPSEC.',
|
||||
'opsec-lab':
|
||||
'Operational security discussion. Share techniques, tools, and threat models. Help each other stay invisible.',
|
||||
};
|
||||
|
||||
interface GateViewProps {
|
||||
gateName: string;
|
||||
persona: string;
|
||||
entryMode?: 'anonymous' | 'persona' | null;
|
||||
onBack: () => void;
|
||||
onNavigateGate: (gate: string) => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
availableGates: string[];
|
||||
}
|
||||
|
||||
interface GateMessage {
|
||||
event_id: string;
|
||||
event_type?: string;
|
||||
node_id?: string;
|
||||
message?: string;
|
||||
ciphertext?: string;
|
||||
epoch?: number;
|
||||
nonce?: string;
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
gate_envelope?: string;
|
||||
decrypted_message?: string;
|
||||
payload?: {
|
||||
gate?: string;
|
||||
ciphertext?: string;
|
||||
nonce?: string;
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
gate_envelope?: string;
|
||||
reply_to?: string;
|
||||
};
|
||||
gate?: string;
|
||||
timestamp: number;
|
||||
sequence?: number;
|
||||
signature?: string;
|
||||
public_key?: string;
|
||||
public_key_algo?: string;
|
||||
protocol_version?: string;
|
||||
reply_to?: string;
|
||||
ephemeral?: boolean;
|
||||
system_seed?: boolean;
|
||||
fixed_gate?: boolean;
|
||||
}
|
||||
|
||||
interface ReplyContext {
|
||||
eventId: string;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
const GATE_ACCESS_PROOF_TTL_MS = 45_000;
|
||||
const gateAccessHeaderCache = new Map<string, { headers: Record<string, string>; expiresAt: number }>();
|
||||
|
||||
function timeAgo(timestamp: number): string {
|
||||
const ts = Number(timestamp || 0);
|
||||
if (!ts) return 'just now';
|
||||
const delta = Math.max(0, Math.floor(Date.now() / 1000) - ts);
|
||||
if (delta < 60) return `${delta}s`;
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h`;
|
||||
return `${Math.floor(delta / 86400)}d`;
|
||||
}
|
||||
|
||||
interface ThreadedMessage {
|
||||
message: GateMessage;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** Build a flat depth-ordered list: root messages first, then their replies indented beneath. */
|
||||
function buildThreadedList(messages: GateMessage[]): ThreadedMessage[] {
|
||||
const byId = new Map<string, GateMessage>();
|
||||
const childrenOf = new Map<string, GateMessage[]>();
|
||||
|
||||
for (const msg of messages) {
|
||||
const id = String(msg.event_id || '');
|
||||
if (id) byId.set(id, msg);
|
||||
const parent = String(msg.reply_to || '').trim();
|
||||
if (parent) {
|
||||
const siblings = childrenOf.get(parent) || [];
|
||||
siblings.push(msg);
|
||||
childrenOf.set(parent, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
const result: ThreadedMessage[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
function walk(msg: GateMessage, depth: number) {
|
||||
const id = String(msg.event_id || '');
|
||||
if (visited.has(id)) return;
|
||||
visited.add(id);
|
||||
result.push({ message: msg, depth: Math.min(depth, 4) });
|
||||
const children = childrenOf.get(id) || [];
|
||||
children.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
for (const child of children) {
|
||||
walk(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Roots: messages with no reply_to, or reply_to pointing to a missing parent
|
||||
const roots = messages.filter((msg) => {
|
||||
const parent = String(msg.reply_to || '').trim();
|
||||
return !parent || !byId.has(parent);
|
||||
});
|
||||
roots.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
for (const root of roots) {
|
||||
walk(root, 0);
|
||||
}
|
||||
// Any orphans not yet visited (shouldn't happen, but safety net)
|
||||
for (const msg of messages) {
|
||||
if (!visited.has(String(msg.event_id || ''))) {
|
||||
walk(msg, 0);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeGateMessage(message: GateMessage): GateMessage {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return {
|
||||
event_id: '',
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
const payload = message.payload && typeof message.payload === 'object' ? message.payload : undefined;
|
||||
return {
|
||||
...message,
|
||||
gate: String(message.gate ?? payload?.gate ?? ''),
|
||||
ciphertext: String(message.ciphertext ?? payload?.ciphertext ?? ''),
|
||||
nonce: String(message.nonce ?? payload?.nonce ?? ''),
|
||||
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
|
||||
format: String(message.format ?? payload?.format ?? ''),
|
||||
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
|
||||
reply_to: String(message.reply_to ?? payload?.reply_to ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildGateAccessHeaders(gateId: string): Promise<Record<string, string> | undefined> {
|
||||
const normalizedGate = String(gateId || '').trim().toLowerCase();
|
||||
if (!normalizedGate) return undefined;
|
||||
const cached = gateAccessHeaderCache.get(normalizedGate);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.headers;
|
||||
}
|
||||
try {
|
||||
const proof = await controlPlaneJson<{ node_id?: string; ts?: number; proof?: string }>(
|
||||
'/api/wormhole/gate/proof',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gate_id: normalizedGate }),
|
||||
},
|
||||
);
|
||||
const nodeId = String(proof.node_id || '').trim();
|
||||
const gateProof = String(proof.proof || '').trim();
|
||||
const gateTs = String(proof.ts || '').trim();
|
||||
if (!nodeId || !gateProof || !gateTs) return undefined;
|
||||
const headers = {
|
||||
'X-Wormhole-Node-Id': nodeId,
|
||||
'X-Wormhole-Gate-Proof': gateProof,
|
||||
'X-Wormhole-Gate-Ts': gateTs,
|
||||
};
|
||||
gateAccessHeaderCache.set(normalizedGate, {
|
||||
headers,
|
||||
expiresAt: Date.now() + GATE_ACCESS_PROOF_TTL_MS,
|
||||
});
|
||||
return headers;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default function GateView({
|
||||
gateName,
|
||||
persona,
|
||||
entryMode = null,
|
||||
onBack,
|
||||
onNavigateGate,
|
||||
onOpenLiveGate: _onOpenLiveGate,
|
||||
availableGates,
|
||||
}: GateViewProps) {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [messages, setMessages] = useState<GateMessage[]>([]);
|
||||
const [composer, setComposer] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [roomError, setRoomError] = useState('');
|
||||
const [status, setStatus] = useState<WormholeGateKeyStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [replyContext, setReplyContext] = useState<ReplyContext | null>(null);
|
||||
const [reps, setReps] = useState<Record<string, number>>({});
|
||||
const [voteNotice, setVoteNotice] = useState('');
|
||||
const [votedOn, setVotedOn] = useState<Record<string, 1 | -1>>({});
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const gateId = useMemo(() => String(gateName || '').trim().toLowerCase(), [gateName]);
|
||||
const introMessage =
|
||||
GATE_INTROS[gateId] || 'Welcome to this gate. Be civil. The Shadowbroker is watching.';
|
||||
|
||||
const searchMatch = searchInput.startsWith('g/')
|
||||
? availableGates.find((g) => g.startsWith(searchInput.slice(2).toLowerCase()))
|
||||
: null;
|
||||
|
||||
const voteScopeKey = useCallback((targetId: string) => `${gateId}::${String(targetId || '').trim()}`, [gateId]);
|
||||
|
||||
const hydrateMessages = useCallback(async (rawMessages: GateMessage[]): Promise<GateMessage[]> => {
|
||||
const baseMessages = (Array.isArray(rawMessages) ? rawMessages : []).map(normalizeGateMessage);
|
||||
const encrypted = baseMessages
|
||||
.map((message, index) => ({ message, index }))
|
||||
.filter(({ message }) => isEncryptedGateEnvelope(message));
|
||||
|
||||
if (!encrypted.length) {
|
||||
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
|
||||
}
|
||||
|
||||
try {
|
||||
const batch = await decryptWormholeGateMessages(
|
||||
encrypted.map(({ message }) => ({
|
||||
gate_id: String(message.gate || gateId),
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
})),
|
||||
);
|
||||
const results = Array.isArray(batch.results) ? batch.results : [];
|
||||
const nextMessages = [...baseMessages];
|
||||
encrypted.forEach(({ index, message }, resultIndex) => {
|
||||
const decrypted = results[resultIndex];
|
||||
nextMessages[index] = {
|
||||
...message,
|
||||
decrypted_message: decrypted?.ok
|
||||
? (decrypted.self_authored && !decrypted.plaintext
|
||||
? (decrypted.legacy
|
||||
? '[legacy gate message — pre-encryption-fix]'
|
||||
: '[your message — plaintext not cached]')
|
||||
: String(decrypted.plaintext || ''))
|
||||
: '',
|
||||
epoch: decrypted?.ok ? Number(decrypted.epoch || message.epoch || 0) : message.epoch,
|
||||
};
|
||||
});
|
||||
return nextMessages;
|
||||
} catch {
|
||||
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
|
||||
}
|
||||
}, [gateId]);
|
||||
|
||||
const refreshGate = useCallback(async () => {
|
||||
if (!gateId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextStatus = await fetchWormholeGateKeyStatus(gateId);
|
||||
setStatus(nextStatus);
|
||||
if (!nextStatus?.ok || !nextStatus.has_local_access) {
|
||||
setMessages([]);
|
||||
setRoomError(String(nextStatus?.detail || 'Gate access still syncing'));
|
||||
return;
|
||||
}
|
||||
const headers = await buildGateAccessHeaders(gateId);
|
||||
if (!headers) {
|
||||
setMessages([]);
|
||||
setRoomError('Gate proof unavailable');
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ limit: '40', gate: gateId });
|
||||
const res = await fetch(`${API_BASE}/api/mesh/infonet/messages?${params}`, { headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setMessages([]);
|
||||
setRoomError(String(data?.detail || 'Failed to load gate room'));
|
||||
return;
|
||||
}
|
||||
const hydrated = await hydrateMessages(Array.isArray(data.messages) ? data.messages : []);
|
||||
const chronological = [...hydrated].reverse();
|
||||
setMessages(chronological);
|
||||
setRoomError('');
|
||||
|
||||
const uniqueEventIds = Array.from(
|
||||
new Set(
|
||||
chronological
|
||||
.map((message) => String(message.event_id || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
if (uniqueEventIds.length > 0) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
for (const eid of uniqueEventIds) params.append('node_id', eid);
|
||||
const repRes = await fetch(`${API_BASE}/api/mesh/reputation/batch?${params}`);
|
||||
if (repRes.ok) {
|
||||
const repData = await repRes.json();
|
||||
const freshReps: Record<string, number> = {};
|
||||
if (repData.reputations && typeof repData.reputations === 'object') {
|
||||
for (const [k, v] of Object.entries(repData.reputations)) {
|
||||
freshReps[k] = Number(v || 0);
|
||||
}
|
||||
}
|
||||
if (Object.keys(freshReps).length > 0) {
|
||||
setReps((prev) => ({ ...prev, ...freshReps }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore batch rep fetch failure */
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [gateId, hydrateMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshGate();
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshGate();
|
||||
}, 8000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [refreshGate]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
const target = searchInput.trim().toLowerCase();
|
||||
if (target.startsWith('g/')) {
|
||||
const nextGate = target.slice(2);
|
||||
if (availableGates.includes(nextGate)) {
|
||||
onNavigateGate(nextGate);
|
||||
setSearchInput('');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const msg = composer.trim();
|
||||
if (!msg || busy || !gateId) return;
|
||||
if (!status?.has_local_access) {
|
||||
setRoomError('Gate access still syncing');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setRoomError('');
|
||||
try {
|
||||
await controlPlaneJson<{ ok: boolean; detail?: string }>('/api/wormhole/gate/message/post', {
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
plaintext: msg,
|
||||
reply_to: replyContext?.eventId || '',
|
||||
}),
|
||||
});
|
||||
setComposer('');
|
||||
setReplyContext(null);
|
||||
// Optimistic: append a placeholder message so the user sees it immediately,
|
||||
// then let the next poll cycle (8s) hydrate it with the real encrypted copy.
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
event_id: `_pending_${Date.now()}`,
|
||||
message: msg,
|
||||
decrypted_message: msg,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
node_id: persona,
|
||||
gate: gateId,
|
||||
reply_to: replyContext?.eventId || '',
|
||||
ephemeral: true,
|
||||
} as GateMessage,
|
||||
]);
|
||||
// Non-blocking background refresh to pick up the real message
|
||||
void refreshGate();
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Gate post failed';
|
||||
// Suppress technical sequence/replay errors — just show a clean retry hint
|
||||
if (/replay|sequence/i.test(errMsg)) {
|
||||
setRoomError('Message could not be posted — try again');
|
||||
} else {
|
||||
setRoomError(errMsg);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, composer, gateId, persona, refreshGate, replyContext, status?.has_local_access]);
|
||||
|
||||
const handleVote = useCallback(async (eventId: string, vote: 1 | -1) => {
|
||||
if (!eventId || !gateId || votedOn[voteScopeKey(eventId)] === vote) return;
|
||||
setVotedOn((prev) => ({ ...prev, [voteScopeKey(eventId)]: vote }));
|
||||
try {
|
||||
const payload = { target_id: eventId, vote, gate: gateId };
|
||||
const valid = validateEventPayload('vote', payload);
|
||||
if (!valid.ok) {
|
||||
throw new Error(`invalid vote payload: ${valid.reason}`);
|
||||
}
|
||||
const sequence = nextSequence();
|
||||
const signed = await signMeshEvent('vote', payload, sequence, { gateId });
|
||||
const response = await fetch(`${API_BASE}/api/mesh/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
voter_id: signed.context.nodeId,
|
||||
target_id: eventId,
|
||||
vote,
|
||||
gate: gateId,
|
||||
voter_pubkey: signed.context.publicKey,
|
||||
public_key_algo: signed.context.publicKeyAlgo,
|
||||
voter_sig: signed.signature,
|
||||
sequence: signed.sequence,
|
||||
protocol_version: signed.protocolVersion,
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data?.ok === false) {
|
||||
throw new Error(String(data?.detail || 'Vote failed'));
|
||||
}
|
||||
// Use the real weight from the backend for the optimistic score update.
|
||||
// The next poll cycle (8s) will reconcile with the real backend score.
|
||||
const w = typeof data?.weight === 'number' ? data.weight : 1;
|
||||
setReps((prev) => ({
|
||||
...prev,
|
||||
[eventId]: Math.round(((prev[eventId] ?? 0) + vote * w) * 10) / 10,
|
||||
}));
|
||||
} catch (err) {
|
||||
// Revert vote state
|
||||
setVotedOn((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[voteScopeKey(eventId)];
|
||||
return next;
|
||||
});
|
||||
// Show brief notice for duplicate votes
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
if (/already set|one vote/i.test(msg)) {
|
||||
setVoteNotice('One vote per post');
|
||||
setTimeout(() => setVoteNotice(''), 3000);
|
||||
}
|
||||
}
|
||||
}, [gateId, voteScopeKey, votedOn]);
|
||||
|
||||
const threadedMessages = useMemo(() => buildThreadedList(messages), [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<div className="text-gray-500 text-xs">
|
||||
LOGGED IN AS:{' '}
|
||||
<span
|
||||
className={
|
||||
persona === 'shadowbroker' ? 'text-red-500 animate-pulse font-bold' : 'text-green-400'
|
||||
}
|
||||
>
|
||||
{persona}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 mt-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest">g/{gateId}</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Fixed obfuscated gate. Creation is disabled for this testnet.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void refreshGate()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 border border-cyan-500/30 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-900/30 transition-colors text-[10px] uppercase tracking-[0.22em]"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 border border-gray-800 bg-gray-900/20 text-xs text-gray-400">
|
||||
<p className="font-bold text-cyan-400 mb-1">=== GATE RULES ===</p>
|
||||
<p>1. FIXED LAUNCH CATALOG: no new gates can be created in this build.</p>
|
||||
<p>2. POSTS + REPLIES PERSIST ON THE OBFUSCATED GATE STORE FOR NODES THAT CARRY THIS GATE.</p>
|
||||
<p>3. GATE VOTES USE THE EXISTING PUBLIC LEDGER VOTE CONTRACT FOR RECORDKEEPING.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 border border-amber-900/30 bg-amber-950/10 text-[11px] text-amber-200/80 leading-relaxed">
|
||||
{entryMode === 'anonymous'
|
||||
? 'Anonymous session is active for this gate. The backend rotates a fresh gate-scoped public key here. You can read, post, reply, and cast the current gate-scoped votes from this room.'
|
||||
: 'Saved gate face is active for this room. Posts stay scoped to this gate while the room history persists on the obfuscated gate lane.'}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-[10px] font-mono text-cyan-400/85">
|
||||
{status?.has_local_access
|
||||
? `LIVE ROOM READY • ${status.identity_scope || entryMode || 'gate'} access`
|
||||
: loading
|
||||
? 'CONNECTING TO OBFUSCATED GATE LANE...'
|
||||
: String(status?.detail || 'Gate access still syncing')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 relative shrink-0">
|
||||
<div className="flex items-center border border-gray-800 bg-[#0a0a0a] p-2">
|
||||
<Search size={14} className="text-gray-600 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search posts or type g/[gate] to jump..."
|
||||
className="bg-transparent border-none outline-none text-white w-full text-sm placeholder-gray-700"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{searchMatch && searchInput.length > 2 && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-[#0a0a0a] border border-gray-800 p-2 text-xs text-gray-400 z-20">
|
||||
Jump to:{' '}
|
||||
<span
|
||||
className="text-white font-bold cursor-pointer"
|
||||
onClick={() => {
|
||||
onNavigateGate(searchMatch);
|
||||
setSearchInput('');
|
||||
}}
|
||||
>
|
||||
g/{searchMatch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{roomError ? (
|
||||
<div className="mb-3 shrink-0 border border-red-900/30 bg-red-950/10 px-3 py-2 text-[11px] text-red-300">
|
||||
{roomError}
|
||||
</div>
|
||||
) : null}
|
||||
{voteNotice ? (
|
||||
<div className="mb-2 shrink-0 border border-yellow-800/30 bg-yellow-950/10 px-3 py-1.5 text-[10px] text-yellow-400/80 font-mono">
|
||||
{voteNotice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-3 pb-4 styled-scrollbar">
|
||||
{!messages.length && (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-3">
|
||||
<div className="text-xs mb-1 text-gray-500">
|
||||
Posted by:{' '}
|
||||
<span className="text-red-500 font-bold animate-pulse drop-shadow-[0_0_5px_rgba(239,68,68,0.8)]">
|
||||
shadowbroker
|
||||
</span>
|
||||
<span className="text-gray-600 ml-2">PINNED</span>
|
||||
</div>
|
||||
<h2 className="text-sm md:text-base text-gray-300 leading-relaxed">{introMessage}</h2>
|
||||
<div className="mt-3 pt-2 border-t border-gray-800/50 text-[10px] text-amber-400/70 tracking-wider uppercase">
|
||||
Fixed launch gate for the testnet catalog. Dynamic gate creation is disabled.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{threadedMessages.map(({ message, depth }) =>
|
||||
message.system_seed ? (
|
||||
<div key={message.event_id} className="border border-cyan-900/30 bg-cyan-950/10 px-3 py-3 max-w-3xl">
|
||||
<div className="text-[8px] font-mono tracking-[0.28em] text-cyan-300/85">
|
||||
{message.fixed_gate ? 'FIXED GATE NOTICE' : 'GATE NOTICE'}
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] font-mono text-cyan-100/80 leading-[1.7]">
|
||||
{message.message}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={message.event_id}
|
||||
className="flex"
|
||||
style={{ paddingLeft: depth * 24 }}
|
||||
>
|
||||
{depth > 0 && (
|
||||
<div className="flex-shrink-0 w-[2px] bg-cyan-900/30 mr-3 self-stretch" />
|
||||
)}
|
||||
<div className={`flex-1 border ${depth > 0 ? 'border-gray-800/40 bg-black/10' : 'border-gray-800/70 bg-black/20'} px-3 py-3`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono">
|
||||
<span className="text-green-400" title={String(message.public_key || message.node_id || '')}>
|
||||
@{String(message.node_id || '').replace(/^!sb_/, '').slice(0, 8)
|
||||
|| String(message.public_key || '').slice(0, 8)
|
||||
|| 'unknown'}
|
||||
</span>
|
||||
{isEncryptedGateEnvelope(message) ? (
|
||||
<span
|
||||
className={`text-[8px] px-1 border ${
|
||||
gateEnvelopeState(message) === 'decrypted'
|
||||
? 'text-cyan-300 border-cyan-700/60'
|
||||
: 'text-amber-300 border-amber-700/60'
|
||||
}`}
|
||||
>
|
||||
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'KEY LOCKED'}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[var(--text-muted)] text-[9px]">{timeAgo(message.timestamp)}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-2 text-[12px] leading-[1.7] whitespace-pre-wrap break-words ${
|
||||
isEncryptedGateEnvelope(message) && !String(message.decrypted_message || '').trim()
|
||||
? 'text-gray-500 italic'
|
||||
: 'text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{gateEnvelopeDisplayText(message)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setReplyContext({
|
||||
eventId: String(message.event_id || ''),
|
||||
nodeId: String(message.node_id || ''),
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[9px] uppercase tracking-[0.18em] border border-cyan-900/40 text-cyan-400 hover:bg-cyan-950/20"
|
||||
>
|
||||
<Reply size={11} />
|
||||
Reply
|
||||
</button>
|
||||
{message.event_id ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => void handleVote(String(message.event_id || ''), 1)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 text-[9px] uppercase tracking-[0.18em] border ${
|
||||
votedOn[voteScopeKey(String(message.event_id || ''))] === 1
|
||||
? 'border-cyan-400/60 text-cyan-300 bg-cyan-950/20'
|
||||
: 'border-cyan-900/40 text-cyan-500 hover:bg-cyan-950/20'
|
||||
}`}
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleVote(String(message.event_id || ''), -1)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 text-[9px] uppercase tracking-[0.18em] border ${
|
||||
votedOn[voteScopeKey(String(message.event_id || ''))] === -1
|
||||
? 'border-red-400/60 text-red-300 bg-red-950/20'
|
||||
: 'border-cyan-900/40 text-red-400 hover:bg-red-950/20'
|
||||
}`}
|
||||
>
|
||||
<ArrowDown size={11} />
|
||||
Down
|
||||
</button>
|
||||
<span className="text-[10px] font-mono text-cyan-400/70">
|
||||
SCORE {(() => { const s = reps[String(message.event_id || '')] ?? 0; return s % 1 === 0 ? s : s.toFixed(1); })()}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 pt-3 mt-2 border-t border-gray-800/50">
|
||||
{replyContext ? (
|
||||
<div className="mb-2 flex items-center justify-between gap-2 border border-amber-900/30 bg-amber-950/10 px-3 py-2 text-[10px] text-amber-200/80">
|
||||
<span>
|
||||
Replying to @{replyContext.eventId.slice(0, 8)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setReplyContext(null)}
|
||||
className="text-amber-300 hover:text-amber-100 uppercase tracking-[0.18em]"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={composer}
|
||||
onChange={(e) => setComposer(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Post into this gate..."
|
||||
className="flex-1 min-h-[72px] max-h-[140px] bg-black/40 border border-cyan-900/40 text-gray-100 px-3 py-2 outline-none resize-y placeholder:text-gray-700"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleSend()}
|
||||
disabled={busy || !composer.trim() || !status?.has_local_access}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-900/30 transition-colors text-[10px] uppercase tracking-[0.22em] disabled:opacity-40"
|
||||
>
|
||||
<Send size={13} />
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
const ROADMAP_ITEMS = [
|
||||
{
|
||||
title: 'Obfuscated lane hardening',
|
||||
detail: 'Continue tightening identity boundaries, transport posture, and secure comms behavior without overstating guarantees.',
|
||||
status: 'ONGOING',
|
||||
type: 'PRIVACY',
|
||||
},
|
||||
{
|
||||
title: 'Participant-node federation',
|
||||
detail: 'Keep improving bootstrap, sync clarity, and real multi-node propagation for the public testnet.',
|
||||
status: 'TESTNET',
|
||||
type: 'NETWORK',
|
||||
},
|
||||
{
|
||||
title: 'Fixed-gate polish',
|
||||
detail: 'Wire the existing Wormhole gates cleanly, keep gate creation disabled, and focus on smooth participation.',
|
||||
status: 'IN PROGRESS',
|
||||
type: 'GATES',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HashchainEvents() {
|
||||
return (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-3 w-64 hidden lg:block shrink-0 h-fit">
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase border-b border-gray-800 pb-2">
|
||||
<Calendar size={14} className="mr-2" /> Privacy Roadmap
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{ROADMAP_ITEMS.map((item, i) => (
|
||||
<div key={i} className="group cursor-pointer">
|
||||
<div className="flex justify-between items-center mb-0.5">
|
||||
<span className="text-[10px] text-green-400 uppercase tracking-widest border border-gray-800 px-1">
|
||||
{item.type}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-cyan-400">
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-300 group-hover:text-white transition-colors mt-1">
|
||||
{item.title}
|
||||
</p>
|
||||
<div className="text-[10px] text-gray-500 mt-1 leading-relaxed">
|
||||
{item.detail}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Shield, RefreshCw, Trash2, Lock, Globe, MessageSquare, DoorOpen, User, Coins } from 'lucide-react';
|
||||
|
||||
type Domain = 'ROOT' | 'TRANSPORT' | 'DM_ALIAS' | 'GATE_SESSION' | 'GATE_PERSONA' | 'COIN';
|
||||
|
||||
interface DomainConfig {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
visibility: 'NEVER PUBLIC' | 'PUBLIC' | 'SEMI-OBFUSCATED' | 'GATE-SCOPED' | 'NEVER LINKED';
|
||||
color: string;
|
||||
}
|
||||
|
||||
const DOMAINS: Record<Domain, DomainConfig> = {
|
||||
ROOT: { name: 'ROOT', icon: <Lock size={14} />, visibility: 'NEVER PUBLIC', color: 'text-red-500' },
|
||||
TRANSPORT: { name: 'TRANSPORT', icon: <Globe size={14} />, visibility: 'PUBLIC', color: 'text-green-400' },
|
||||
DM_ALIAS: { name: 'DM_ALIAS', icon: <MessageSquare size={14} />, visibility: 'SEMI-OBFUSCATED', color: 'text-cyan-400' },
|
||||
GATE_SESSION: { name: 'GATE_SESSION', icon: <DoorOpen size={14} />, visibility: 'GATE-SCOPED', color: 'text-cyan-400' },
|
||||
GATE_PERSONA: { name: 'GATE_PERSONA', icon: <User size={14} />, visibility: 'GATE-SCOPED', color: 'text-cyan-400' },
|
||||
COIN: { name: 'COIN', icon: <Coins size={14} />, visibility: 'NEVER LINKED', color: 'text-red-400' },
|
||||
};
|
||||
|
||||
export default function IdentityHUD({ currentDomain = 'TRANSPORT' }: { currentDomain?: Domain }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const domain = DOMAINS[currentDomain];
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 right-4 z-[3] flex flex-col items-end">
|
||||
{isExpanded && (
|
||||
<div className="mb-2 w-64 bg-[#0a0a0a] border border-gray-800 p-3 shadow-[0_0_20px_rgba(6,182,212,0.1)]">
|
||||
<div className="flex justify-between items-center mb-3 border-b border-gray-800 pb-2">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest font-bold">Identity Domains</span>
|
||||
<button onClick={() => setIsExpanded(false)} className="text-gray-500 hover:text-white">×</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(Object.keys(DOMAINS) as Domain[]).map((key) => {
|
||||
const d = DOMAINS[key];
|
||||
const isActive = key === currentDomain;
|
||||
return (
|
||||
<div key={key} className={`p-2 border ${isActive ? 'border-cyan-500 bg-cyan-500/5' : 'border-gray-800 bg-gray-900/20'} flex items-center justify-between group`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isActive ? 'text-cyan-400' : 'text-gray-600'}>{d.icon}</span>
|
||||
<div>
|
||||
<p className={`text-[10px] font-bold tracking-tighter ${isActive ? 'text-white' : 'text-gray-500'}`}>{d.name}</p>
|
||||
<p className="text-[8px] text-gray-600 uppercase">{d.visibility}</p>
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex gap-1">
|
||||
<button title="Rotate Identity" className="p-1 text-gray-600 hover:text-cyan-400 transition-colors">
|
||||
<RefreshCw size={10} />
|
||||
</button>
|
||||
<button title="Purge Domain Data" className="p-1 text-gray-600 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-2 border-t border-gray-800">
|
||||
<p className="text-[8px] text-red-500/70 uppercase leading-tight">
|
||||
CRITICAL: CROSS-DOMAIN LINKAGE IS PROTOCOL-FORBIDDEN.
|
||||
ROTATING IDENTITY PURGES ALL LOCAL SESSION CACHE.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={`flex items-center gap-3 px-4 py-2 border ${isExpanded ? 'border-cyan-500 bg-cyan-900/20' : 'border-gray-800 bg-gray-900/80'} backdrop-blur-md transition-all hover:border-cyan-400 group`}
|
||||
>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest leading-none mb-1">Active Domain</span>
|
||||
<span className={`text-xs font-bold tracking-widest ${domain.color} flex items-center gap-1`}>
|
||||
{domain.icon} {domain.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-8 w-[1px] bg-gray-800 group-hover:bg-cyan-500/50 transition-colors" />
|
||||
<Shield size={18} className={isExpanded ? 'text-cyan-400' : 'text-gray-500 group-hover:text-cyan-400'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail } from 'lucide-react';
|
||||
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
activateWormholeGatePersona,
|
||||
createWormholeGatePersona,
|
||||
enterWormholeGate,
|
||||
fetchWormholeIdentity,
|
||||
listWormholeGatePersonas,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import GateView from './GateView';
|
||||
import MarketView from './MarketView';
|
||||
import ProfileView from './ProfileView';
|
||||
import MessagesView from './MessagesView';
|
||||
import TerminalDashboard from './TerminalDashboard';
|
||||
import WeatherWidget from './WeatherWidget';
|
||||
import TrendingPosts from './TrendingPosts';
|
||||
import HashchainEvents from './HashchainEvents';
|
||||
import NetworkStats from './NetworkStats';
|
||||
|
||||
|
||||
const ASCII_HEADER = `
|
||||
T H E
|
||||
██╗███╗ ██╗███████╗██████╗ ███╗ ██╗███████╗████████╗
|
||||
██║████╗ ██║██╔════╝██╔═══██╗████╗ ██║██╔════╝╚══██╔══╝
|
||||
██║██╔██╗ ██║█████╗ ██║ ██║██╔██╗ ██║█████╗ ██║
|
||||
██║██║╚██╗██║██╔══╝ ██║ ██║██║╚██╗██║██╔══╝ ██║
|
||||
██║██║ ╚████║██║ ╚██████╔╝██║ ╚████║███████╗ ██║
|
||||
╚═╝╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ ╚═╝
|
||||
C O M M O N S
|
||||
|
||||
======================================
|
||||
INFONET SOVEREIGN SHELL v0.1.1 (TEST)
|
||||
TEST-NET CONNECTION ESTABLISHED
|
||||
======================================
|
||||
`;
|
||||
|
||||
const COMING_SOON_MODULES: Record<string, { title: string; desc: string; status: string }> = {
|
||||
BALLOT: {
|
||||
title: 'BALLOT — DEMOCRACY FOR ALL SOON',
|
||||
desc: 'Governance surfaces are not live in this testnet shell yet. When they arrive, they should reflect real community demand, clear rules, and verifiable participation instead of placeholder politics.',
|
||||
status: 'MODULE STATUS: HOLDING SCREEN ONLY — NO LIVE BALLOTS OR COUNTS',
|
||||
},
|
||||
GIGS: {
|
||||
title: 'GIGS — NETWORK BOUNTIES',
|
||||
desc: 'Decentralized work contracts, intelligence bounties, and mesh task allocation. Accept jobs, deliver payloads, and earn credits through verified proof-of-work completion.',
|
||||
status: 'MODULE STATUS: TESTNET ONLY — CONTRACT ENGINE IN DEVELOPMENT',
|
||||
},
|
||||
EXCHANGE: {
|
||||
title: 'EXCHANGE — DECENTRALIZED TRADING',
|
||||
desc: 'Zero-KYC peer-to-peer asset exchange. Trade crypto against credits with on-chain order books, stealth addresses, and privacy-preserving settlement.',
|
||||
status: 'MODULE STATUS: TESTNET ONLY — LIQUIDITY POOLS NOT YET ACTIVE',
|
||||
},
|
||||
};
|
||||
|
||||
const GATES = [
|
||||
'infonet', 'general-talk', 'gathered-intel', 'tracked-planes',
|
||||
'ukraine-front', 'iran-front', 'world-news', 'prediction-markets',
|
||||
'finance', 'cryptography', 'cryptocurrencies', 'meet-chat', 'opsec-lab'
|
||||
];
|
||||
|
||||
const SHELL_ANON_PERSONAS_KEY = 'sb_infonet_shell_anon_personas';
|
||||
|
||||
function readShellAnonPersonas(): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SHELL_ANON_PERSONAS_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.map((value) => String(value || '').trim()).filter(Boolean) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeShellAnonPersonas(personas: string[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(SHELL_ANON_PERSONAS_KEY, JSON.stringify(personas));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function allocateShellAnonPersona(): string {
|
||||
const existing = readShellAnonPersonas();
|
||||
const used = new Set(existing.map((persona) => persona.toLowerCase()));
|
||||
for (let attempt = 0; attempt < 10_000; attempt += 1) {
|
||||
const candidate = `anon_${Math.floor(100 + Math.random() * 9_900)}`;
|
||||
if (used.has(candidate.toLowerCase())) continue;
|
||||
writeShellAnonPersonas([...existing, candidate]);
|
||||
return candidate;
|
||||
}
|
||||
const fallback = `anon_${Date.now()}`;
|
||||
writeShellAnonPersonas([...existing, fallback]);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const SECTIONS = [
|
||||
{ name: 'HELP', icon: <Terminal size={14} className="mr-2" /> },
|
||||
{ name: 'BALLOT', icon: <Vote size={14} className="mr-2" /> },
|
||||
{ name: 'GIGS', icon: <Briefcase size={14} className="mr-2" /> },
|
||||
{ name: 'MESH', icon: <Globe size={14} className="mr-2" /> },
|
||||
{ name: 'GATES', icon: <Key size={14} className="mr-2" /> },
|
||||
{ name: 'MARKETS', icon: <Activity size={14} className="mr-2" /> },
|
||||
{ name: 'EXCHANGE', icon: <ArrowRightLeft size={14} className="mr-2" /> },
|
||||
{ name: 'PROFILE', icon: <User size={14} className="mr-2" /> },
|
||||
{ name: 'MESSAGES', icon: <Mail size={14} className="mr-2" /> },
|
||||
{ name: 'EXIT', icon: <LogOut size={14} className="mr-2" /> },
|
||||
];
|
||||
|
||||
interface CommandHistory {
|
||||
command: string;
|
||||
output: React.ReactNode;
|
||||
}
|
||||
|
||||
interface InfonetShellProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
}
|
||||
|
||||
export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: InfonetShellProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [history, setHistory] = useState<CommandHistory[]>([]);
|
||||
const [isBooting, setIsBooting] = useState(true);
|
||||
const [bootText, setBootText] = useState<string[]>([]);
|
||||
|
||||
// Navigation & State
|
||||
const [currentView, setCurrentView] = useState<'terminal' | 'gate' | 'market' | 'profile' | 'messages'>('terminal');
|
||||
const [activeGate, setActiveGate] = useState<string | null>(null);
|
||||
const [persona, setPersona] = useState<string | null>(null);
|
||||
const [activeGateMode, setActiveGateMode] = useState<'anonymous' | 'persona' | null>(null);
|
||||
const [inputMode, setInputMode] = useState<'normal' | 'persona'>('normal');
|
||||
const [pendingGate, setPendingGate] = useState<string | null>(null);
|
||||
const [isCitizen] = useState(false);
|
||||
const [comingSoonModule, setComingSoonModule] = useState<string | null>(null);
|
||||
const [wormholePromptKey, setWormholePromptKey] = useState('');
|
||||
|
||||
const endOfTerminalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Real mesh identity
|
||||
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
|
||||
const wormholeDescriptor = useMemo(() => getWormholeIdentityDescriptor(), []);
|
||||
const promptHost = useMemo(
|
||||
() =>
|
||||
String(
|
||||
nodeIdentity?.publicKey || wormholePromptKey || wormholeDescriptor?.publicKey || 'no-public-key',
|
||||
).trim() || 'no-public-key',
|
||||
[nodeIdentity?.publicKey, wormholeDescriptor?.publicKey, wormholePromptKey],
|
||||
);
|
||||
const shellPrompt = `${isCitizen ? 'citizen' : 'sovereign'}@${promptHost}:~$`;
|
||||
|
||||
/* Reset + boot sequence when opened */
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Reset state
|
||||
setHistory([]);
|
||||
setCurrentView('terminal');
|
||||
setActiveGate(null);
|
||||
setPersona(null);
|
||||
setActiveGateMode(null);
|
||||
setInputMode('normal');
|
||||
setPendingGate(null);
|
||||
setInput('');
|
||||
setIsBooting(true);
|
||||
setBootText([]);
|
||||
|
||||
const bootLines = [
|
||||
'INITIALIZING KERNEL...',
|
||||
'LOADING MODULES: [OK]',
|
||||
'MOUNTING VFS: [OK]',
|
||||
'STARTING NETWORK INTERFACES...',
|
||||
'CONNECTING TO INFONET MESH...',
|
||||
'ESTABLISHING SECURE TUNNEL...',
|
||||
'HANDSHAKE COMPLETE.',
|
||||
'WELCOME SOVEREIGN.'
|
||||
];
|
||||
|
||||
let currentLine = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (currentLine < bootLines.length) {
|
||||
setBootText(prev => [...prev, bootLines[currentLine]]);
|
||||
currentLine++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => setIsBooting(false), 500);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen]);
|
||||
|
||||
/* Focus input after boot — scoped to container */
|
||||
useEffect(() => {
|
||||
if (!isBooting && isOpen) {
|
||||
inputRef.current?.focus();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const handleGlobalClick = () => {
|
||||
if (window.getSelection()?.toString()) return;
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
container.addEventListener('click', handleGlobalClick);
|
||||
return () => container.removeEventListener('click', handleGlobalClick);
|
||||
}
|
||||
}, [isBooting, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isOpen || nodeIdentity?.publicKey) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const identity = await fetchWormholeIdentity();
|
||||
if (!cancelled) {
|
||||
setWormholePromptKey(String(identity?.public_key || '').trim());
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setWormholePromptKey('');
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, nodeIdentity?.publicKey]);
|
||||
|
||||
/* Scroll to bottom */
|
||||
useEffect(() => {
|
||||
endOfTerminalRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [history]);
|
||||
|
||||
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages', gate?: string) => {
|
||||
if (view === 'gate' && gate) {
|
||||
if (onOpenLiveGate) {
|
||||
setPendingGate(gate);
|
||||
setInputMode('persona');
|
||||
setHistory(prev => [...prev, {
|
||||
command: `join ${gate}`,
|
||||
output: (
|
||||
<span className="text-cyan-400">
|
||||
Type a gate face label to open the encrypted room, or type
|
||||
{' '}
|
||||
<span className="font-bold text-white">anon</span>
|
||||
{' '}
|
||||
for a rotating obfuscated session that opens the room under a fresh gate-scoped key.
|
||||
{' '}
|
||||
<span className="text-red-400">'shadowbroker' is reserved.</span>
|
||||
</span>
|
||||
)
|
||||
}]);
|
||||
return;
|
||||
}
|
||||
setActiveGate(gate);
|
||||
setActiveGateMode(persona ? 'persona' : 'anonymous');
|
||||
}
|
||||
setCurrentView(view);
|
||||
};
|
||||
|
||||
const handleCommand = (cmd: string) => {
|
||||
const trimmedCmd = cmd.trim().toLowerCase();
|
||||
let output: React.ReactNode = '';
|
||||
|
||||
if (trimmedCmd === '') return;
|
||||
|
||||
if (inputMode === 'persona') {
|
||||
if (trimmedCmd === 'shadowbroker') {
|
||||
output = <span className="text-red-500 font-bold animate-pulse">ERR: Persona 'shadowbroker' is reserved and cannot be claimed.</span>;
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
return;
|
||||
}
|
||||
if (!pendingGate) {
|
||||
setInputMode('normal');
|
||||
output = <span className="text-red-400">ERR: No pending gate launch target.</span>;
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
return;
|
||||
}
|
||||
const chosenPersona = trimmedCmd === 'anon' ? allocateShellAnonPersona() : cmd.trim();
|
||||
setPersona(chosenPersona);
|
||||
setInputMode('normal');
|
||||
const gateTarget = pendingGate;
|
||||
if (trimmedCmd === 'anon') {
|
||||
output = (
|
||||
<span className="text-amber-300">
|
||||
Rotating anonymous gate key for g/{gateTarget}...
|
||||
</span>
|
||||
);
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
setPendingGate(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await enterWormholeGate(gateTarget, true);
|
||||
setActiveGateMode('anonymous');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'anonymous_gate_enter_failed';
|
||||
setHistory(prev => [...prev, {
|
||||
command: `gate ${gateTarget}`,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
output = <span className="text-green-400">Creating gate face '{chosenPersona}' for g/{gateTarget}...</span>;
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
setPendingGate(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const personas = await listWormholeGatePersonas(gateTarget);
|
||||
const existing = Array.isArray(personas?.personas)
|
||||
? personas.personas.find(
|
||||
(candidate) =>
|
||||
String(candidate?.label || '').trim().toLowerCase() === chosenPersona.toLowerCase(),
|
||||
)
|
||||
: null;
|
||||
const result = existing?.persona_id
|
||||
? await activateWormholeGatePersona(gateTarget, existing.persona_id)
|
||||
: await createWormholeGatePersona(gateTarget, chosenPersona);
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.detail || 'gate_face_create_failed');
|
||||
}
|
||||
setActiveGateMode('persona');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'gate_face_create_failed';
|
||||
setHistory(prev => [...prev, {
|
||||
command: `join ${gateTarget}`,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedCmd === 'help') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p>AVAILABLE COMMANDS:</p>
|
||||
<ul className="list-disc list-inside ml-2 mt-1">
|
||||
<li><span className="text-gray-300 font-bold">help</span> - Display this message</li>
|
||||
<li><span className="text-gray-300 font-bold">clear</span> - Clear terminal output</li>
|
||||
<li><span className="text-gray-300 font-bold">mesh</span> - Access public mesh ledger</li>
|
||||
<li><span className="text-gray-300 font-bold">radio</span> - Open SIGINT / radio surfaces</li>
|
||||
<li><span className="text-gray-300 font-bold">messages</span> - Open Secure Comms</li>
|
||||
<li><span className="text-gray-300 font-bold">profile</span> - View sovereign identity & ledger</li>
|
||||
<li><span className="text-gray-300 font-bold">ballot</span> - View democratic proposals</li>
|
||||
<li><span className="text-gray-300 font-bold">gigs</span> - View network bounties & jobs</li>
|
||||
<li><span className="text-gray-300 font-bold">markets</span> - View prediction markets</li>
|
||||
<li><span className="text-gray-300 font-bold">exchange</span> - Decentralized crypto exchange</li>
|
||||
<li><span className="text-gray-300 font-bold">wormhole</span> - Check secure tunneling status</li>
|
||||
<li><span className="text-gray-300 font-bold">gates</span> - List available obfuscated gates</li>
|
||||
<li><span className="text-gray-300 font-bold">join [gate]</span> - Choose anonymous entry or a gate face, then enter the room</li>
|
||||
<li><span className="text-gray-300 font-bold">exit</span> - Disconnect from Infonet</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd === 'clear') {
|
||||
setHistory([]);
|
||||
return;
|
||||
} else if (trimmedCmd === 'gates') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p>AVAILABLE OBFUSCATED GATES:</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mt-2">
|
||||
{GATES.map(gate => (
|
||||
<div key={gate} className="flex items-center cursor-pointer hover:text-gray-300 group" onClick={() => handleNavigate('gate', gate)}>
|
||||
<span className="text-gray-500 mr-2 group-hover:text-cyan-400 transition-colors">[{'>'}]</span>
|
||||
<span className="text-gray-300 group-hover:text-white transition-colors group-hover:drop-shadow-[0_0_5px_rgba(6,182,212,0.8)]">{gate}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd.startsWith('join ') || trimmedCmd.startsWith('g/')) {
|
||||
const target = trimmedCmd.startsWith('g/') ? trimmedCmd.slice(2) : trimmedCmd.split(' ')[1];
|
||||
if (GATES.includes(target)) {
|
||||
handleNavigate('gate', target);
|
||||
return;
|
||||
} else {
|
||||
output = <span className="text-red-400">ERR: Gate '{target}' not found or access denied.</span>;
|
||||
}
|
||||
} else if (trimmedCmd === 'markets') {
|
||||
handleNavigate('market');
|
||||
return;
|
||||
} else if (trimmedCmd === 'messages') {
|
||||
handleNavigate('messages');
|
||||
return;
|
||||
} else if (trimmedCmd === 'profile') {
|
||||
handleNavigate('profile');
|
||||
return;
|
||||
} else if (trimmedCmd === 'ballot') {
|
||||
setComingSoonModule('BALLOT');
|
||||
return;
|
||||
} else if (trimmedCmd === 'work' || trimmedCmd === 'gigs') {
|
||||
setComingSoonModule('GIGS');
|
||||
return;
|
||||
} else if (trimmedCmd === 'exchange') {
|
||||
setComingSoonModule('EXCHANGE');
|
||||
return;
|
||||
} else if (trimmedCmd === 'mesh') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p>SYNCING PUBLIC MESH LEDGER...</p>
|
||||
<p className="text-gray-500 mt-1">Block: #894921 | Hash: 0x9f8a...2b1c</p>
|
||||
<p className="text-gray-500">Block: #894920 | Hash: 0x3e1d...9a4f</p>
|
||||
<p className="text-gray-500">Block: #894919 | Hash: 0x7c2b...1e8d</p>
|
||||
<p className="text-green-400 mt-2">Ledger synchronized.</p>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd === 'radio') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p className="flex items-center"><Radio size={14} className="mr-2 animate-pulse text-red-400" /> SCANNING FREQUENCIES...</p>
|
||||
<p className="text-gray-500 mt-1">144.390 MHz - APRS traffic detected</p>
|
||||
<p className="text-gray-500">462.562 MHz - Encrypted burst</p>
|
||||
<p className="text-gray-500">8.992 MHz - EAM broadcast intercepted</p>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd === 'wormhole') {
|
||||
output = (
|
||||
<div className="text-gray-400">
|
||||
<p>OBFUSCATED LANE STATUS:</p>
|
||||
<p className="text-gray-500 mt-1">Status: <span className="text-green-400">ONLINE</span></p>
|
||||
<p className="text-gray-500">Active Tunnels: 3</p>
|
||||
<p className="text-gray-500 mt-2">Use <span className="text-gray-300 font-bold">join [gate]</span> to open an obfuscated gate room.</p>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmedCmd === 'whoami') {
|
||||
output = (
|
||||
<span className="text-gray-400">
|
||||
{`${persona || 'unassigned'}${nodeIdentity?.nodeId ? ` (${nodeIdentity.nodeId})` : ''}${nodeIdentity?.publicKey ? ` / ${nodeIdentity.publicKey}` : ''}`}
|
||||
</span>
|
||||
);
|
||||
} else if (trimmedCmd === 'date') {
|
||||
output = <span className="text-gray-400">{new Date().toISOString()}</span>;
|
||||
} else if (trimmedCmd === 'exit') {
|
||||
onClose();
|
||||
return;
|
||||
} else {
|
||||
output = <span className="text-red-400">Command not recognized: {trimmedCmd}. Type 'help' for available commands.</span>;
|
||||
}
|
||||
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) {
|
||||
handleCommand(`join ${searchMatch}`);
|
||||
} else {
|
||||
handleCommand(input);
|
||||
}
|
||||
setInput('');
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) {
|
||||
setInput(`g/${searchMatch}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Autocomplete logic
|
||||
const searchMatch = (inputMode === 'normal' && input.startsWith('g/'))
|
||||
? GATES.find(g => g.startsWith(input.slice(2).toLowerCase()))
|
||||
: null;
|
||||
|
||||
if (isBooting) {
|
||||
return (
|
||||
<div className="h-full bg-[#0a0a0a] text-gray-300 p-4 md:p-8 font-mono flex flex-col justify-end pb-20 overflow-hidden">
|
||||
<div className="space-y-1">
|
||||
{bootText.map((line, i) => (
|
||||
<div key={i} className="text-gray-400">{line}</div>
|
||||
))}
|
||||
<div className="animate-pulse w-2 h-4 bg-white mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full bg-[#0a0a0a] text-gray-300 p-4 md:p-8 font-mono relative flex flex-col overflow-hidden">
|
||||
{currentView === 'terminal' && (
|
||||
<>
|
||||
{/* Top Navigation / Quick Launch */}
|
||||
<div className="flex flex-row justify-between items-center gap-2 mb-6 border-b border-gray-800/50 pb-4 shrink-0 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<div className="flex flex-nowrap gap-1.5">
|
||||
{SECTIONS.map((section) => (
|
||||
<button
|
||||
key={section.name}
|
||||
onClick={() => handleCommand(section.name === 'PROFILE' ? 'profile' : section.name.toLowerCase())}
|
||||
className="flex items-center px-2 py-1 bg-cyan-900/10 border border-cyan-900/50 text-cyan-500 hover:bg-cyan-900/30 hover:text-cyan-400 hover:border-cyan-500/50 transition-all text-[10px] md:text-xs uppercase tracking-widest whitespace-nowrap"
|
||||
>
|
||||
{section.icon}
|
||||
{section.name === 'PROFILE' ? 'SOVEREIGN' : section.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<WeatherWidget />
|
||||
</div>
|
||||
|
||||
{/* Main Terminal Area */}
|
||||
<div className="flex-1 overflow-y-auto pr-4 pb-4">
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start gap-6 mb-8">
|
||||
<TrendingPosts />
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<pre
|
||||
className="text-white drop-shadow-[0_0_8px_rgba(156,163,175,0.8)] text-[10px] sm:text-xs md:text-sm leading-tight select-none text-left inline-block"
|
||||
style={{ fontFamily: 'Consolas, "Courier New", monospace' }}
|
||||
>
|
||||
{ASCII_HEADER}
|
||||
</pre>
|
||||
<div className="text-gray-400/80 text-center mt-4">
|
||||
<p>Welcome to Infonet. Type <span className="text-green-400 font-bold">'help'</span> to see available commands.</p>
|
||||
<p>Type <span className="text-green-400 font-bold">'gates'</span> or <span className="text-green-400 font-bold">g/</span> to view available chatrooms.</p>
|
||||
</div>
|
||||
<NetworkStats />
|
||||
</div>
|
||||
|
||||
<HashchainEvents />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TerminalDashboard onNavigate={(view) => handleNavigate(view)} onComingSoon={(mod) => setComingSoonModule(mod)} />
|
||||
|
||||
{history.map((entry, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center text-white">
|
||||
<span className="text-gray-500 mr-2 inline-block max-w-[45%] truncate" title={shellPrompt}>
|
||||
{shellPrompt}
|
||||
</span>
|
||||
<span>{entry.command}</span>
|
||||
</div>
|
||||
<div className="ml-4 text-gray-300">
|
||||
{entry.output}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={endOfTerminalRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="shrink-0 pt-4 mt-2 border-t border-gray-800/50 z-10 relative">
|
||||
{searchMatch && input.length > 2 && (
|
||||
<div className="absolute bottom-full left-0 mb-2 bg-[#0a0a0a] border border-gray-800 p-2 text-xs text-gray-400 z-20">
|
||||
Jump to: <span className="text-white font-bold">g/{searchMatch}</span> [Press Tab to autocomplete, Enter to join]
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center max-w-full">
|
||||
<span
|
||||
className={`text-gray-500 mr-2 ${inputMode === 'persona' ? 'whitespace-nowrap' : 'inline-block max-w-[45%] truncate'}`}
|
||||
title={inputMode === 'persona' ? 'Enter Persona:' : shellPrompt}
|
||||
>
|
||||
{inputMode === 'persona' ? 'Enter Persona: ' : shellPrompt}
|
||||
</span>
|
||||
<div className="relative flex-1 flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-transparent border-none outline-none text-white placeholder-gray-800 focus:ring-0 caret-transparent"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{/* Custom cursor */}
|
||||
<span
|
||||
className="absolute animate-pulse w-2 h-4 bg-white pointer-events-none"
|
||||
style={{ left: `${input.length}ch` }}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentView === 'gate' && activeGate && (
|
||||
<GateView
|
||||
gateName={activeGate}
|
||||
persona={persona || 'anon'}
|
||||
entryMode={activeGateMode}
|
||||
onBack={() => handleNavigate('terminal')}
|
||||
onNavigateGate={(gate) => handleNavigate('gate', gate)}
|
||||
onOpenLiveGate={onOpenLiveGate}
|
||||
availableGates={GATES}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'market' && (
|
||||
<MarketView onBack={() => handleNavigate('terminal')} />
|
||||
)}
|
||||
|
||||
{currentView === 'profile' && (
|
||||
<ProfileView
|
||||
onBack={() => handleNavigate('terminal')}
|
||||
persona={persona || 'unassigned'}
|
||||
isCitizen={isCitizen}
|
||||
nodeId={nodeIdentity?.nodeId}
|
||||
publicKey={nodeIdentity?.publicKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'messages' && (
|
||||
<MessagesView onBack={() => handleNavigate('terminal')} />
|
||||
)}
|
||||
|
||||
{/* Coming Soon Popup */}
|
||||
{comingSoonModule && COMING_SOON_MODULES[comingSoonModule] && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-[3px]">
|
||||
<div className="border border-cyan-500/30 bg-[#060a0f] shadow-[0_0_40px_rgba(6,182,212,0.1),inset_0_0_60px_rgba(6,182,212,0.03)] max-w-md w-full mx-4">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-cyan-900/40 bg-cyan-950/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse shadow-[0_0_6px_rgba(245,158,11,0.6)]" />
|
||||
<span className="text-[9px] tracking-[0.3em] text-amber-400/80 uppercase">System Notice</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setComingSoonModule(null)}
|
||||
className="text-gray-600 hover:text-white text-xs transition-colors"
|
||||
>
|
||||
[×]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="text-cyan-400 text-xs tracking-[0.25em] uppercase font-bold mb-4">
|
||||
{COMING_SOON_MODULES[comingSoonModule].title}
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-3 mb-4">
|
||||
<p className="text-[11px] text-gray-400 leading-relaxed">
|
||||
{COMING_SOON_MODULES[comingSoonModule].desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4 px-1">
|
||||
<span className="w-1 h-1 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span className="text-[9px] tracking-[0.2em] text-amber-400/90 uppercase">
|
||||
{COMING_SOON_MODULES[comingSoonModule].status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-4 flex items-center justify-between">
|
||||
<span className="text-[8px] text-gray-600 tracking-[0.2em] uppercase">
|
||||
Infonet Sovereign Shell v0.1.1 — Test-Net
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setComingSoonModule(null)}
|
||||
className="px-4 py-1.5 border border-cyan-900/50 bg-cyan-950/20 text-cyan-400 text-[10px] tracking-[0.2em] uppercase hover:bg-cyan-900/30 hover:border-cyan-500/40 transition-all"
|
||||
>
|
||||
Acknowledged
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import type { DashboardData } from '@/types/dashboard';
|
||||
|
||||
interface ActivityLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
gate: string;
|
||||
user: string;
|
||||
content: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'text-cyan-400',
|
||||
'text-fuchsia-400',
|
||||
'text-emerald-400',
|
||||
'text-violet-400',
|
||||
'text-rose-400',
|
||||
'text-blue-400',
|
||||
'text-lime-400',
|
||||
'text-amber-400',
|
||||
];
|
||||
|
||||
function pickColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return COLORS[Math.abs(hash) % COLORS.length];
|
||||
}
|
||||
|
||||
function timeStr(): string {
|
||||
return new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'news' | 'trending_markets' | 'commercial_flights' | 'military_flights' | 'ships' | 'correlations' | 'threat_level' | 'liveuamap'>;
|
||||
const DATA_KEYS = ['news', 'trending_markets', 'commercial_flights', 'military_flights', 'ships', 'correlations', 'threat_level', 'liveuamap'] as const;
|
||||
|
||||
export default function LiveActivityLog() {
|
||||
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevDataRef = useRef<DataSlice | null>(null);
|
||||
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
|
||||
// Generate event logs from real data changes
|
||||
useEffect(() => {
|
||||
const prev = prevDataRef.current;
|
||||
if (!prev) {
|
||||
// First load — generate initial summary logs
|
||||
prevDataRef.current = data;
|
||||
const initial: ActivityLog[] = [];
|
||||
const now = timeStr();
|
||||
|
||||
if (data?.news?.length) {
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'SYS', user: 'SYSTEM', content: `Wire feed loaded: ${data.news.length} articles indexed`, color: 'text-gray-500' });
|
||||
}
|
||||
if (data?.trending_markets?.length) {
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'SYS', user: 'SYSTEM', content: `${data.trending_markets.length} prediction markets synced from Polymarket/Kalshi`, color: 'text-gray-500' });
|
||||
}
|
||||
if (data?.commercial_flights?.length || data?.military_flights?.length) {
|
||||
const total = (data.commercial_flights?.length || 0) + (data.military_flights?.length || 0);
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'tracked-planes', user: 'SYSTEM', content: `ADS-B: ${total} flights tracked`, color: 'text-gray-500' });
|
||||
}
|
||||
if (data?.ships?.length) {
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'SYS', user: 'SYSTEM', content: `AIS: ${data.ships.length} vessels tracked`, color: 'text-gray-500' });
|
||||
}
|
||||
if (data?.threat_level) {
|
||||
initial.push({ id: crypto.randomUUID(), timestamp: now, gate: 'SYS', user: 'SYSTEM', content: `Threat level: ${data.threat_level.level} (${data.threat_level.score}/100)`, color: pickColor('threat') });
|
||||
}
|
||||
|
||||
if (initial.length > 0) setLogs(initial);
|
||||
return;
|
||||
}
|
||||
|
||||
// Diff to generate new events
|
||||
const newLogs: ActivityLog[] = [];
|
||||
const now = timeStr();
|
||||
|
||||
// New articles
|
||||
if (data?.news && prev.news) {
|
||||
const prevIds = new Set(prev.news.map(n => n.id));
|
||||
const newArticles = data.news.filter(n => !prevIds.has(n.id));
|
||||
for (const article of newArticles.slice(0, 3)) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'world-news', user: article.source || 'WIRE',
|
||||
content: article.title,
|
||||
color: article.breaking ? 'text-red-400' : pickColor(article.source || 'news'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// New markets
|
||||
if (data?.trending_markets && prev.trending_markets) {
|
||||
const prevSlugs = new Set(prev.trending_markets.map(m => m.slug));
|
||||
const newMarkets = data.trending_markets.filter(m => !prevSlugs.has(m.slug));
|
||||
for (const market of newMarkets.slice(0, 2)) {
|
||||
const pct = market.consensus_pct ?? market.polymarket_pct ?? 0;
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'prediction-markets', user: 'ORACLE',
|
||||
content: `New market: "${market.title}" (${pct}% YES)`,
|
||||
color: 'text-amber-400',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flight count changes
|
||||
const curFlights = (data?.commercial_flights?.length || 0) + (data?.military_flights?.length || 0);
|
||||
const prevFlights = (prev.commercial_flights?.length || 0) + (prev.military_flights?.length || 0);
|
||||
if (Math.abs(curFlights - prevFlights) > 5) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'tracked-planes', user: 'ADS-B',
|
||||
content: `Flight count ${curFlights > prevFlights ? 'increased' : 'decreased'}: ${prevFlights} → ${curFlights}`,
|
||||
color: 'text-cyan-400',
|
||||
});
|
||||
}
|
||||
|
||||
// Ship count changes
|
||||
const curShips = data?.ships?.length || 0;
|
||||
const prevShips = prev.ships?.length || 0;
|
||||
if (Math.abs(curShips - prevShips) > 3) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'SYS', user: 'AIS',
|
||||
content: `Vessel count updated: ${prevShips} → ${curShips}`,
|
||||
color: 'text-blue-400',
|
||||
});
|
||||
}
|
||||
|
||||
// Correlation alerts
|
||||
if (data?.correlations && prev.correlations) {
|
||||
const prevCount = prev.correlations.length;
|
||||
const curCount = data.correlations.length;
|
||||
if (curCount > prevCount) {
|
||||
const newCorrels = data.correlations.slice(prevCount);
|
||||
for (const corr of newCorrels.slice(0, 2)) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'gathered-intel', user: 'CORRELATION-ENGINE',
|
||||
content: `${corr.type.replace(/_/g, ' ').toUpperCase()} [${corr.severity}] — ${corr.drivers.slice(0, 2).join(', ')}`,
|
||||
color: 'text-fuchsia-400',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Threat level changes
|
||||
if (data?.threat_level && prev.threat_level && data.threat_level.level !== prev.threat_level.level) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'SYS', user: 'SYSTEM',
|
||||
content: `THREAT LEVEL CHANGE: ${prev.threat_level.level} → ${data.threat_level.level}`,
|
||||
color: data.threat_level.level === 'SEVERE' ? 'text-red-400' : 'text-yellow-400',
|
||||
});
|
||||
}
|
||||
|
||||
// LiveUAMap events
|
||||
if (data?.liveuamap && prev.liveuamap) {
|
||||
const prevLiveIds = new Set(prev.liveuamap.map(e => e.id));
|
||||
const newEvents = data.liveuamap.filter(e => !prevLiveIds.has(e.id));
|
||||
for (const evt of newEvents.slice(0, 2)) {
|
||||
newLogs.push({
|
||||
id: crypto.randomUUID(), timestamp: now,
|
||||
gate: 'ukraine-front', user: 'LIVEUAMAP',
|
||||
content: evt.title || evt.description || 'New conflict event',
|
||||
color: 'text-rose-400',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newLogs.length > 0) {
|
||||
setLogs(prev => {
|
||||
const updated = [...prev, ...newLogs];
|
||||
if (updated.length > 50) return updated.slice(-50);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
prevDataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<div className="mt-6 border border-gray-800 bg-[#0a0a0a] p-3 shrink-0 flex flex-col h-48">
|
||||
<div className="flex items-center justify-between mb-2 border-b border-gray-800 pb-2 shrink-0">
|
||||
<h3 className="text-cyan-400 font-bold flex items-center text-xs tracking-widest uppercase">
|
||||
<Activity size={14} className="mr-2 animate-pulse text-green-400" />
|
||||
Live Network Telemetry
|
||||
</h3>
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
FEEDS: {logs.length} EVENTS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto font-mono text-[10px] sm:text-xs space-y-1.5 pr-2 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-gray-800"
|
||||
>
|
||||
{logs.length === 0 && (
|
||||
<div className="text-gray-600 italic text-center py-4">Waiting for data stream...</div>
|
||||
)}
|
||||
{logs.map(log => (
|
||||
<div key={log.id} className={`flex items-start gap-2 hover:bg-white/5 px-1 py-0.5 transition-colors ${log.color}`}>
|
||||
<span className="opacity-50 shrink-0">[{log.timestamp}]</span>
|
||||
<span className="opacity-75 shrink-0">[{log.gate}]</span>
|
||||
<span className="opacity-90 shrink-0">@{log.user}:</span>
|
||||
<span className="flex-1 break-words brightness-110">{log.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import type { DashboardData, StockTicker } from '@/types/dashboard';
|
||||
|
||||
function formatVolume(vol: number): string {
|
||||
if (!vol || vol <= 0) return '';
|
||||
if (vol >= 1_000_000) return `$${(vol / 1_000_000).toFixed(1)}M`;
|
||||
if (vol >= 1_000) return `$${(vol / 1_000).toFixed(0)}K`;
|
||||
return `$${vol.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function formatEndDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const days = Math.floor((d.getTime() - now.getTime()) / 86400000);
|
||||
if (days < 0) return 'EXPIRED';
|
||||
if (days === 0) return 'TODAY';
|
||||
if (days === 1) return '1d';
|
||||
if (days < 30) return `${days}d`;
|
||||
if (days < 365) return `${Math.floor(days / 30)}mo`;
|
||||
return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
const CATEGORY_CONFIG: Record<string, { color: string; icon: typeof Shield }> = {
|
||||
POLITICS: { color: 'text-blue-400', icon: Shield },
|
||||
CONFLICT: { color: 'text-red-400', icon: Crosshair },
|
||||
FINANCE: { color: 'text-emerald-400', icon: DollarSign },
|
||||
CRYPTO: { color: 'text-amber-400', icon: DollarSign },
|
||||
NEWS: { color: 'text-cyan-400', icon: Newspaper },
|
||||
};
|
||||
|
||||
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'NEWS';
|
||||
|
||||
interface MarketViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'trending_markets' | 'stocks'>;
|
||||
const DATA_KEYS = ['trending_markets', 'stocks'] as const;
|
||||
|
||||
export default function MarketView({ onBack }: MarketViewProps) {
|
||||
const [category, setCategory] = useState<Category>('ALL');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
const markets = data?.trending_markets || [];
|
||||
const stocks = data?.stocks;
|
||||
|
||||
const filteredMarkets = markets.filter(m => {
|
||||
const matchesCat = category === 'ALL' || m.category === category;
|
||||
const matchesSearch = !searchInput || m.title.toLowerCase().includes(searchInput.toLowerCase());
|
||||
return matchesCat && matchesSearch;
|
||||
});
|
||||
|
||||
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'NEWS'];
|
||||
|
||||
// Build ticker from real stocks data
|
||||
const tickerItems: string[] = [];
|
||||
if (stocks) {
|
||||
const entries = Object.entries(stocks as Record<string, StockTicker>).filter(([k]) => !['last_updated', 'source'].includes(k));
|
||||
for (const [symbol, val] of entries) {
|
||||
if (val && val.change_percent != null) {
|
||||
const up = val.change_percent >= 0;
|
||||
tickerItems.push(`${symbol.toUpperCase()} ${up ? '▲' : '▼'} ${Math.abs(val.change_percent).toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50 mb-4"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<Activity className="mr-2 text-cyan-400 animate-pulse" />
|
||||
PREDICTION MARKETS
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Live Polymarket + Kalshi feeds. {markets.length} active markets tracked.</p>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4 shrink-0">
|
||||
<div className="flex gap-2 overflow-x-auto w-full md:w-auto pb-2 md:pb-0">
|
||||
{CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategory(cat)}
|
||||
className={`px-3 py-1 text-xs uppercase tracking-widest border whitespace-nowrap ${
|
||||
category === cat
|
||||
? 'bg-gray-800 text-white border-white'
|
||||
: 'bg-gray-900/30 text-gray-500 border-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500 font-mono">{filteredMarkets.length} RESULTS</span>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4 shrink-0">
|
||||
<div className="flex items-center border border-gray-800 bg-[#0a0a0a] p-2">
|
||||
<Search size={14} className="text-gray-600 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search prediction markets..."
|
||||
className="bg-transparent border-none outline-none text-white w-full text-sm placeholder-gray-700"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markets List */}
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-3 pb-4">
|
||||
{filteredMarkets.length > 0 ? filteredMarkets.map((market, i) => {
|
||||
const pct = market.consensus_pct ?? market.polymarket_pct ?? market.kalshi_pct ?? 0;
|
||||
const catConfig = CATEGORY_CONFIG[market.category] || { color: 'text-gray-400' };
|
||||
const vol = formatVolume(market.volume);
|
||||
const vol24 = formatVolume(market.volume_24h);
|
||||
// Runtime-optional fields the backend may send but aren't in the strict TS type
|
||||
const raw = market as Record<string, unknown>;
|
||||
const endDate = formatEndDate(typeof raw.end_date === 'string' ? raw.end_date : null);
|
||||
const outcomes = market.outcomes && market.outcomes.length > 0 ? market.outcomes : null;
|
||||
const consensus = raw.consensus as { total_picks: number; total_staked: number } | undefined;
|
||||
|
||||
return (
|
||||
<div key={market.slug || i} className="border border-gray-800 bg-gray-900/10 p-4 hover:border-gray-600 transition-colors">
|
||||
{/* Title + Category */}
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-gray-300 font-bold text-sm md:text-base leading-snug">{market.title}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-[10px] font-mono">
|
||||
<span className={`${catConfig.color} uppercase tracking-widest`}>{market.category}</span>
|
||||
{vol && <span className="text-gray-500">VOL: {vol}</span>}
|
||||
{vol24 && <span className="text-gray-500">24H: {vol24}</span>}
|
||||
{endDate && <span className="text-gray-500">CLOSES: {endDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
{outcomes && outcomes.length > 0 ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-cyan-400 font-mono">{outcomes[0].pct}%</div>
|
||||
<div className="text-[9px] text-gray-400 uppercase truncate max-w-[100px]" title={outcomes[0].name}>{outcomes[0].name}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-emerald-400 font-mono">{pct}%</div>
|
||||
<div className="text-[9px] text-gray-500 uppercase">CONSENSUS</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Probability bar */}
|
||||
{outcomes && outcomes.length > 0 ? (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] text-cyan-400 font-mono truncate max-w-[80px]" title={outcomes[0].name}>{outcomes[0].name}</span>
|
||||
<div className="flex-1 h-2 bg-gray-900 overflow-hidden flex">
|
||||
<div className="bg-cyan-500/60" style={{ width: `${outcomes[0].pct}%` }} />
|
||||
<div className="bg-gray-700/30 flex-1" />
|
||||
</div>
|
||||
<span className="text-[9px] text-cyan-400 font-mono w-8 text-right">{outcomes[0].pct}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] text-green-400 font-mono w-8">YES</span>
|
||||
<div className="flex-1 h-2 bg-gray-900 overflow-hidden flex">
|
||||
<div className="bg-emerald-500/60" style={{ width: `${pct}%` }} />
|
||||
<div className="bg-red-500/30 flex-1" />
|
||||
</div>
|
||||
<span className="text-[9px] text-red-400 font-mono w-8 text-right">NO</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source badges */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{market.sources?.map((s, si) => (
|
||||
<span key={si} className={`text-[9px] font-mono px-1.5 py-0.5 border ${
|
||||
s.name === 'POLY'
|
||||
? 'bg-purple-500/15 text-purple-400 border-purple-500/20'
|
||||
: 'bg-blue-500/15 text-blue-400 border-blue-500/20'
|
||||
}`}>
|
||||
{s.name} {s.pct}%
|
||||
</span>
|
||||
))}
|
||||
{consensus && consensus.total_picks > 0 && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 border bg-amber-500/10 text-amber-400 border-amber-500/20">
|
||||
{consensus.total_picks} pick{consensus.total_picks !== 1 ? 's' : ''}
|
||||
{consensus.total_staked > 0 ? ` · ${consensus.total_staked.toFixed(1)} REP` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delta indicator */}
|
||||
{market.delta_pct != null && market.delta_pct !== 0 && (
|
||||
<span className={`text-[10px] font-mono font-bold ${market.delta_pct > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{market.delta_pct > 0 ? '▲' : '▼'} {Math.abs(market.delta_pct).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Multi-choice outcomes */}
|
||||
{outcomes && outcomes.length > 0 && (
|
||||
<div className="mt-3 pt-2 border-t border-gray-800 space-y-1">
|
||||
{outcomes.slice(0, 5).map((outcome, oi) => (
|
||||
<div key={oi} className="flex items-center gap-2 text-[10px]">
|
||||
<span className="text-gray-400 w-24 truncate">{outcome.name}</span>
|
||||
<div className="flex-1 h-1 bg-gray-900 overflow-hidden">
|
||||
<div className="bg-cyan-500/50 h-full" style={{ width: `${outcome.pct}%` }} />
|
||||
</div>
|
||||
<span className="text-cyan-400 font-mono w-8 text-right">{outcome.pct}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="text-center text-gray-600 py-8">
|
||||
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ticker */}
|
||||
{tickerItems.length > 0 && (
|
||||
<div className="shrink-0 border-t border-gray-800 bg-gray-900/30 overflow-hidden py-2 mt-2">
|
||||
<div className="animate-ticker text-gray-400 font-bold text-sm tracking-widest whitespace-nowrap">
|
||||
{Array(10).fill(tickerItems.join(' | ')).join(' | ').split(' | ').map((item, i) => {
|
||||
const isUp = item.includes('▲');
|
||||
return (
|
||||
<span key={i} className="mx-4">
|
||||
{item.replace(/[▲▼]/, '')}
|
||||
<span className={isUp ? 'text-green-400 ml-1' : 'text-red-400 ml-1'}>
|
||||
{isUp ? '▲' : '▼'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { fetchInfonetNodeStatusSnapshot } from '@/mesh/controlPlaneStatusClient';
|
||||
|
||||
interface Stats {
|
||||
meshtastic: number;
|
||||
aprs: number;
|
||||
infonetNodes: number;
|
||||
infonetEvents: number;
|
||||
syncPeers: number;
|
||||
nodeEnabled: boolean;
|
||||
syncOutcome: string;
|
||||
}
|
||||
|
||||
const EMPTY: Stats = {
|
||||
meshtastic: 0, aprs: 0, infonetNodes: 0, infonetEvents: 0,
|
||||
syncPeers: 0, nodeEnabled: false, syncOutcome: 'offline',
|
||||
};
|
||||
|
||||
export default function NetworkStats() {
|
||||
const [stats, setStats] = useState<Stats>(EMPTY);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const [meshRes, channelsRes, infonet] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/mesh/status`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetch(`${API_BASE}/api/mesh/channels`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
||||
]);
|
||||
if (!alive) return;
|
||||
setStats({
|
||||
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
|
||||
aprs: Number(meshRes?.signal_counts?.aprs || 0),
|
||||
infonetNodes: Number(infonet?.known_nodes || 0),
|
||||
infonetEvents: Number(infonet?.total_events || 0),
|
||||
syncPeers: Number(infonet?.bootstrap?.sync_peer_count || 0),
|
||||
nodeEnabled: Boolean(infonet?.node_enabled),
|
||||
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
poll();
|
||||
const interval = setInterval(poll, 15000);
|
||||
return () => { alive = false; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
const nodeColor = stats.syncOutcome === 'ok' ? 'text-green-400'
|
||||
: stats.nodeEnabled ? 'text-amber-400' : 'text-gray-600';
|
||||
const nodeLabel = stats.syncOutcome === 'ok' ? 'CONNECTED'
|
||||
: stats.syncOutcome === 'running' ? 'SYNCING'
|
||||
: stats.nodeEnabled ? 'SYNCING' : 'OFFLINE';
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 mt-5 text-[10px] font-mono text-gray-500">
|
||||
<span>NODE <span className={nodeColor}>{nodeLabel}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>MESH <span className={stats.meshtastic > 0 ? 'text-green-400' : 'text-gray-600'}>{stats.meshtastic.toLocaleString()}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>APRS <span className={stats.aprs > 0 ? 'text-green-400' : 'text-gray-600'}>{stats.aprs.toLocaleString()}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>INFONET NODES <span className="text-white">{stats.infonetNodes}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>EVENTS <span className="text-white">{stats.infonetEvents}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>PEERS <span className="text-white">{stats.syncPeers}</span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, User, Eye, EyeOff, Wallet, Activity, ShieldCheck, AlertCircle } from 'lucide-react';
|
||||
|
||||
import { API_BASE } from '@/lib/api';
|
||||
|
||||
interface ProfileViewProps {
|
||||
onBack: () => void;
|
||||
persona: string;
|
||||
isCitizen: boolean;
|
||||
nodeId?: string | null;
|
||||
publicKey?: string | null;
|
||||
}
|
||||
|
||||
interface ReputationSummary {
|
||||
overall: number;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
}
|
||||
|
||||
interface OracleProfileSummary {
|
||||
oracle_rep: number;
|
||||
oracle_rep_total: number;
|
||||
oracle_rep_locked: number;
|
||||
predictions_won: number;
|
||||
predictions_lost: number;
|
||||
win_rate: number;
|
||||
}
|
||||
|
||||
const EMPTY_REPUTATION: ReputationSummary = {
|
||||
overall: 0,
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
};
|
||||
|
||||
const EMPTY_ORACLE_PROFILE: OracleProfileSummary = {
|
||||
oracle_rep: 0,
|
||||
oracle_rep_total: 0,
|
||||
oracle_rep_locked: 0,
|
||||
predictions_won: 0,
|
||||
predictions_lost: 0,
|
||||
win_rate: 0,
|
||||
};
|
||||
|
||||
export default function ProfileView({ onBack, persona, isCitizen, nodeId, publicKey }: ProfileViewProps) {
|
||||
const [showWallet, setShowWallet] = useState(false);
|
||||
const [showBalance, setShowBalance] = useState(true);
|
||||
const [showTransactions, setShowTransactions] = useState(false);
|
||||
const [reputation, setReputation] = useState<ReputationSummary>(EMPTY_REPUTATION);
|
||||
const [oracleProfile, setOracleProfile] = useState<OracleProfileSummary>(EMPTY_ORACLE_PROFILE);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const loadProfileStats = async () => {
|
||||
if (!nodeId) {
|
||||
setReputation(EMPTY_REPUTATION);
|
||||
setOracleProfile(EMPTY_ORACLE_PROFILE);
|
||||
return;
|
||||
}
|
||||
|
||||
const [repResult, oracleResult] = await Promise.allSettled([
|
||||
fetch(`${API_BASE}/api/mesh/reputation?node_id=${encodeURIComponent(nodeId)}`, { cache: 'no-store' }),
|
||||
fetch(`${API_BASE}/api/mesh/oracle/profile?node_id=${encodeURIComponent(nodeId)}`, { cache: 'no-store' }),
|
||||
]);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (repResult.status === 'fulfilled' && repResult.value.ok) {
|
||||
try {
|
||||
const data = await repResult.value.json();
|
||||
if (active) {
|
||||
setReputation({
|
||||
overall: Number(data?.overall || 0),
|
||||
upvotes: Number(data?.upvotes || 0),
|
||||
downvotes: Number(data?.downvotes || 0),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setReputation(EMPTY_REPUTATION);
|
||||
}
|
||||
}
|
||||
} else if (active) {
|
||||
setReputation(EMPTY_REPUTATION);
|
||||
}
|
||||
|
||||
if (oracleResult.status === 'fulfilled' && oracleResult.value.ok) {
|
||||
try {
|
||||
const data = await oracleResult.value.json();
|
||||
if (active) {
|
||||
setOracleProfile({
|
||||
oracle_rep: Number(data?.oracle_rep || 0),
|
||||
oracle_rep_total: Number(data?.oracle_rep_total || 0),
|
||||
oracle_rep_locked: Number(data?.oracle_rep_locked || 0),
|
||||
predictions_won: Number(data?.predictions_won || 0),
|
||||
predictions_lost: Number(data?.predictions_lost || 0),
|
||||
win_rate: Number(data?.win_rate || 0),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setOracleProfile(EMPTY_ORACLE_PROFILE);
|
||||
}
|
||||
}
|
||||
} else if (active) {
|
||||
setOracleProfile(EMPTY_ORACLE_PROFILE);
|
||||
}
|
||||
};
|
||||
|
||||
void loadProfileStats();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [nodeId]);
|
||||
|
||||
const displayNodeId = nodeId?.trim() || 'NOT PROVISIONED';
|
||||
const displayPersona = persona?.trim() || 'unassigned';
|
||||
const creditsReference = publicKey?.trim() || 'Not provisioned';
|
||||
const creditsBalance = 0;
|
||||
const transactions: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
type: string;
|
||||
amount: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
status: string;
|
||||
}> = [];
|
||||
|
||||
const overallRep = reputation.overall;
|
||||
const repProgress = Math.max(0, Math.min(100, overallRep));
|
||||
const upvotes = reputation.upvotes;
|
||||
const downvotes = reputation.downvotes;
|
||||
const oracleRep = oracleProfile.oracle_rep;
|
||||
const oracleRepTotal = oracleProfile.oracle_rep_total;
|
||||
const oracleRepLocked = oracleProfile.oracle_rep_locked;
|
||||
const oracleProgress = oracleRepTotal > 0 ? Math.max(0, Math.min(100, (oracleRep / oracleRepTotal) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50 mb-4"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<User className="mr-2 text-cyan-400" />
|
||||
{isCitizen ? 'CITIZEN' : 'SOVEREIGN'} PROFILE
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Identity, reputation, and credits ledger.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-6 pb-4">
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<User size={16} className="mr-2" /> IDENTITY
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest">Agent ID</p>
|
||||
<p className="text-sm text-cyan-400 font-mono">{displayNodeId}</p>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest mt-3">Current Persona</p>
|
||||
<p className="text-xl text-gray-300 font-bold">{displayPersona}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest">Citizenship Status</p>
|
||||
<p className={`text-xl font-bold ${isCitizen ? 'text-green-400' : 'text-amber-500'}`}>
|
||||
{isCitizen ? 'ACTIVE CITIZEN' : 'SOVEREIGN'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 border-t border-gray-800 pt-4 mt-2">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest">Common Rep (Public Reputation)</p>
|
||||
<p className="text-2xl text-cyan-400 font-bold">
|
||||
{overallRep} <span className="text-sm text-gray-600">net</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-right">
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Lit</p>
|
||||
<p className="text-lg font-bold text-green-400">{upvotes}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Dislikes</p>
|
||||
<p className="text-lg font-bold text-red-400">{downvotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 w-full bg-gray-900 border border-gray-800 overflow-hidden relative">
|
||||
<div
|
||||
className="h-full bg-cyan-400 transition-all duration-500 shadow-[0_0_10px_rgba(6,182,212,0.5)]"
|
||||
style={{ width: `${repProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-gray-500 uppercase tracking-tighter">
|
||||
Reputation is derived from live lit/dislike activity. Net rep can drop below zero even when the bar is clamped at zero.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:col-span-2 mt-2">
|
||||
<div className="p-3 bg-gray-900/40 border border-gray-800">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Active Months</p>
|
||||
<p className="text-xl text-white font-bold">0 MONTHS</p>
|
||||
<p className="text-[9px] text-gray-600 mt-1 uppercase">No live citizenship accounting yet</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/40 border border-gray-800">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Citizenship History</p>
|
||||
<p className="text-xl text-gray-400 font-bold">0 MONTHS</p>
|
||||
<p className="text-[9px] text-gray-600 mt-1 uppercase">Placeholder totals removed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest mb-1">Oracle Rep (Truth)</p>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xl text-cyan-400 font-bold">
|
||||
{oracleRep.toFixed(1)} <span className="text-xs text-gray-500 font-normal">AVAILABLE</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 uppercase">
|
||||
Win Rate {oracleProfile.win_rate}% • W {oracleProfile.predictions_won} / L {oracleProfile.predictions_lost}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-gray-900 border border-gray-800 overflow-hidden mb-1">
|
||||
<div
|
||||
className="h-full bg-cyan-500 transition-all duration-500 shadow-[0_0_10px_rgba(6,182,212,0.5)]"
|
||||
style={{ width: `${oracleProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-tighter">
|
||||
Available: {oracleRep.toFixed(1)} | Locked: {oracleRepLocked.toFixed(1)} | Total: {oracleRepTotal.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<ShieldCheck size={16} className="mr-2" /> NETWORK HEALTH (VCS ANALYSIS)
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex flex-col items-center justify-center p-4 border border-gray-800 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest mb-2">Vote Correlation</p>
|
||||
<div className="relative h-20 w-20">
|
||||
<svg className="h-full w-full" viewBox="0 0 36 36">
|
||||
<path className="stroke-gray-800 stroke-[3]" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
<path className="stroke-gray-500 stroke-[3] transition-all duration-1000" fill="none" strokeDasharray="0, 100" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-gray-400">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[8px] text-gray-500 mt-2 uppercase">NOT CALIBRATED</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Clustering Coefficient</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold">0.00</p>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-gray-900 overflow-hidden">
|
||||
<div className="h-full bg-gray-500 w-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Temporal Burst Detection</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold">0.00</p>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-gray-900 overflow-hidden">
|
||||
<div className="h-full bg-gray-500 w-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 border border-gray-800 bg-gray-900/20 flex items-start gap-2">
|
||||
<AlertCircle size={14} className="text-gray-500 shrink-0 mt-0.5" />
|
||||
<p className="text-[9px] text-gray-500 uppercase leading-tight">
|
||||
Advanced network-health analytics are not calibrated for this profile yet. Live reputation above is authoritative; unresolved analytics stay zeroed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<User size={16} className="mr-2" /> IDENTITY DOMAINS (HARD SEPARATION)
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Root</p>
|
||||
<p className="text-xs text-red-400 font-bold">NEVER PUBLIC</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Transport</p>
|
||||
<p className="text-xs text-green-400 font-bold">PUBLIC MESH</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">DM Alias</p>
|
||||
<p className="text-xs text-cyan-400 font-bold">SEMI-OBFUSCATED</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Gate Session</p>
|
||||
<p className="text-xs text-cyan-400 font-bold">ANONYMOUS</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Gate Persona</p>
|
||||
<p className="text-xs text-cyan-400 font-bold">{displayPersona}</p>
|
||||
</div>
|
||||
<div className="border border-gray-800 p-2 bg-[#0a0a0a]">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-widest">Credits</p>
|
||||
<p className="text-xs text-gray-300 font-bold">0.00 AVAILABLE</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<Wallet size={16} className="mr-2" /> CREDITS LEDGER
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-[#0a0a0a] border border-gray-800">
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest mb-1">Credits Account Reference</p>
|
||||
<p className="text-sm md:text-base text-gray-300 font-mono tracking-wider">
|
||||
{showWallet ? creditsReference : '****************************************'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowWallet(!showWallet)}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors p-2"
|
||||
>
|
||||
{showWallet ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-[#0a0a0a] border border-gray-800">
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest mb-1">Available Credits</p>
|
||||
<p className="text-2xl text-green-400 font-mono font-bold">
|
||||
{showBalance ? `${creditsBalance.toFixed(2)} Credits` : '****.** Credits'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBalance(!showBalance)}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors p-2"
|
||||
>
|
||||
{showBalance ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowTransactions(!showTransactions)}
|
||||
className="flex items-center text-xs text-cyan-400 hover:text-cyan-300 transition-colors uppercase tracking-widest"
|
||||
>
|
||||
<Activity size={14} className="mr-1" />
|
||||
{showTransactions ? 'HIDE CREDITS HISTORY' : 'VIEW CREDITS HISTORY'}
|
||||
</button>
|
||||
|
||||
{showTransactions && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{transactions.length ? (
|
||||
transactions.map((tx) => (
|
||||
<div key={tx.id} className="flex justify-between items-center border border-gray-800 bg-gray-900/10 p-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 mr-4">{tx.date}</span>
|
||||
<span className="text-cyan-400 font-bold mr-2">{tx.type}</span>
|
||||
<span className="text-gray-500 text-xs">
|
||||
{tx.type === 'RECEIVED' ? `FROM: ${tx.from}` : `TO: ${tx.to}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`font-mono font-bold ${tx.amount.startsWith('+') ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{tx.amount} Credits
|
||||
</span>
|
||||
<div className="text-gray-500 text-xs">{tx.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-3 text-sm text-gray-500 uppercase tracking-widest">
|
||||
No credits activity recorded yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Activity, Newspaper, TrendingUp, Vote, ChevronRight } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import type { DashboardData } from '@/types/dashboard';
|
||||
import LiveActivityLog from './LiveActivityLog';
|
||||
|
||||
function formatTime(pubDate: string): string {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const pub = new Date(pubDate).getTime();
|
||||
const diff = Math.floor((now - pub) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
const THREAT_COLORS: Record<string, { text: string; bg: string; border: string }> = {
|
||||
GREEN: { text: 'text-green-400', bg: 'bg-green-900/30', border: 'border-green-900/50' },
|
||||
GUARDED: { text: 'text-blue-400', bg: 'bg-blue-900/30', border: 'border-blue-900/50' },
|
||||
ELEVATED: { text: 'text-yellow-400', bg: 'bg-yellow-900/30', border: 'border-yellow-900/50' },
|
||||
HIGH: { text: 'text-orange-400', bg: 'bg-orange-900/30', border: 'border-orange-900/50' },
|
||||
SEVERE: { text: 'text-red-400', bg: 'bg-red-900/30', border: 'border-red-900/50' },
|
||||
};
|
||||
|
||||
interface TerminalDashboardProps {
|
||||
onNavigate: (view: 'market') => void;
|
||||
onComingSoon?: (module: string) => void;
|
||||
}
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'news' | 'trending_markets' | 'threat_level' | 'commercial_flights' | 'military_flights' | 'ships' | 'satellites' | 'correlations'>;
|
||||
const DATA_KEYS = ['news', 'trending_markets', 'threat_level', 'commercial_flights', 'military_flights', 'ships', 'satellites', 'correlations'] as const;
|
||||
|
||||
export default function TerminalDashboard({ onNavigate, onComingSoon }: TerminalDashboardProps) {
|
||||
const [category, setCategory] = useState('ALL');
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
|
||||
const news = data?.news || [];
|
||||
const markets = data?.trending_markets || [];
|
||||
const threat = data?.threat_level;
|
||||
const threatStyle = THREAT_COLORS[threat?.level || 'ELEVATED'] || THREAT_COLORS.ELEVATED;
|
||||
|
||||
// Count active data layers
|
||||
const flightCount = (data?.commercial_flights?.length || 0) + (data?.military_flights?.length || 0);
|
||||
const shipCount = data?.ships?.length || 0;
|
||||
const satCount = data?.satellites?.length || 0;
|
||||
const correlationCount = data?.correlations?.length || 0;
|
||||
|
||||
// Filter news by category
|
||||
const filteredNews = news.filter(article => {
|
||||
if (category === 'ALL') return true;
|
||||
const title = article.title?.toLowerCase() || '';
|
||||
if (category === 'CONFLICT') return article.risk_score >= 7 || title.includes('military') || title.includes('war') || title.includes('attack') || title.includes('strike');
|
||||
if (category === 'POLITICS') return title.includes('politic') || title.includes('election') || title.includes('government') || title.includes('president') || title.includes('senate');
|
||||
if (category === 'FINANCE') return title.includes('market') || title.includes('stock') || title.includes('economy') || title.includes('bank') || title.includes('trade');
|
||||
if (category === 'TECH') return title.includes('tech') || title.includes('cyber') || title.includes('ai') || title.includes('quantum') || title.includes('hack');
|
||||
return true;
|
||||
}).slice(0, 6);
|
||||
|
||||
// Top 3 markets for dashboard preview
|
||||
const topMarkets = markets.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-4 mb-6 shrink-0">
|
||||
{/* Dashboard Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 border-b border-gray-800 pb-3 gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-cyan-400 uppercase tracking-widest font-bold">GLOBAL THREAT INTERCEPT</span>
|
||||
{threat && (
|
||||
<span className={`text-[10px] px-2 py-0.5 ${threatStyle.bg} ${threatStyle.text} ${threatStyle.border} border animate-pulse font-bold`}>
|
||||
{threat.level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="bg-[#0a0a0a] border border-gray-800 text-gray-300 text-xs p-1.5 outline-none uppercase tracking-widest cursor-pointer hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<option value="ALL">ALL TOPICS</option>
|
||||
<option value="CONFLICT">CONFLICT</option>
|
||||
<option value="POLITICS">POLITICS</option>
|
||||
<option value="FINANCE">FINANCE</option>
|
||||
<option value="TECH">TECH</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Content — 2x2 grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Top Stories (real RSS data) */}
|
||||
<div>
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase">
|
||||
<Newspaper size={14} className="mr-2" /> TOP STORIES
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{filteredNews.length > 0 ? filteredNews.map((article, i) => (
|
||||
<div key={article.id || i} className="group cursor-pointer">
|
||||
<div className="flex items-baseline gap-2 mb-0.5">
|
||||
<span className={`text-[10px] uppercase tracking-widest border border-gray-800 px-1 ${
|
||||
article.risk_score >= 7 ? 'text-red-400' :
|
||||
article.risk_score >= 4 ? 'text-yellow-400' : 'text-green-400'
|
||||
}`}>
|
||||
{article.risk_score >= 7 ? 'HIGH' : article.risk_score >= 4 ? 'MED' : 'LOW'}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600 font-mono uppercase">{article.source}</span>
|
||||
<span className="text-[10px] text-gray-500 font-mono">{formatTime(article.pub_date)}</span>
|
||||
{article.breaking && (
|
||||
<span className="text-[10px] text-red-500 font-bold animate-pulse">BREAKING</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 group-hover:text-white transition-colors leading-snug">{article.title}</p>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-sm text-gray-600 italic">No stories in wire feed.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Popular Markets (real Polymarket/Kalshi data) */}
|
||||
<div>
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase">
|
||||
<TrendingUp size={14} className="mr-2" /> POPULAR MARKETS
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{topMarkets.length > 0 ? topMarkets.map((market, i) => {
|
||||
const outcomes = market.outcomes || [];
|
||||
const isMulti = outcomes.length > 2;
|
||||
const pct = market.consensus_pct ?? market.polymarket_pct ?? market.kalshi_pct ?? 0;
|
||||
return (
|
||||
<div key={market.slug || i} className="group flex flex-col sm:flex-row sm:items-center justify-between gap-2 border border-gray-800 bg-gray-900/20 p-2 hover:border-gray-600 transition-colors cursor-pointer" onClick={() => onNavigate('market')}>
|
||||
<span className="text-sm text-gray-300 group-hover:text-white transition-colors truncate pr-2">{market.title}</span>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{isMulti ? (
|
||||
<>
|
||||
<span className="text-xs px-2 py-1 bg-cyan-900/20 text-cyan-400 border border-cyan-900/50 truncate max-w-[140px]" title={outcomes[0].name}>
|
||||
{outcomes[0].name} {outcomes[0].pct}%
|
||||
</span>
|
||||
{outcomes[1] && (
|
||||
<span className="text-xs px-2 py-1 bg-gray-800/40 text-gray-400 border border-gray-700/50 truncate max-w-[100px]" title={outcomes[1].name}>
|
||||
{outcomes[1].name} {outcomes[1].pct}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs px-2 py-1 bg-green-900/20 text-green-400 border border-green-900/50">Y {pct}%</span>
|
||||
<span className="text-xs px-2 py-1 bg-red-900/20 text-red-400 border border-red-900/50">N {100 - pct}%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<p className="text-sm text-gray-600 italic">No market data available.</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('market')}
|
||||
className="mt-3 text-xs text-cyan-400 hover:text-cyan-300 uppercase tracking-widest flex items-center transition-colors"
|
||||
>
|
||||
View All Markets <ChevronRight size={12} className="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Open ballot placeholder */}
|
||||
<div>
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase">
|
||||
<Vote size={14} className="mr-2" /> OPEN BALLOT
|
||||
</h3>
|
||||
<div
|
||||
className="border border-gray-800 bg-gray-900/20 p-5 cursor-pointer hover:border-gray-600 transition-colors"
|
||||
onClick={() => onComingSoon?.('BALLOT')}
|
||||
>
|
||||
<div className="text-center border border-cyan-900/40 bg-cyan-950/10 px-4 py-8">
|
||||
<div className="text-2xl md:text-3xl font-bold tracking-[0.32em] text-cyan-300">
|
||||
DEMOCRACY FOR ALL SOON
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-gray-400 uppercase tracking-[0.22em]">
|
||||
No live ballot counts or policy promises are being advertised in this shell yet.
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-gray-500 leading-relaxed">
|
||||
When governance arrives, it should be real, verifiable, and community-shaped instead of placeholder politics.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onComingSoon?.('BALLOT')}
|
||||
className="mt-3 text-xs text-cyan-400 hover:text-cyan-300 uppercase tracking-widest flex items-center transition-colors"
|
||||
>
|
||||
View Governance Note <ChevronRight size={12} className="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Network Telemetry (real data) */}
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase">
|
||||
<Activity size={14} className="mr-2" /> D.I.N. TELEMETRY
|
||||
</h3>
|
||||
<div className="flex-1 border border-gray-800 bg-gray-900/20 p-3 flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Tracked Flights</span>
|
||||
<span className="text-xs text-green-400 font-mono">{flightCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Tracked Vessels</span>
|
||||
<span className="text-xs text-cyan-400 font-mono">{shipCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Satellites</span>
|
||||
<span className="text-xs text-gray-300 font-mono">{satCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Active Markets</span>
|
||||
<span className="text-xs text-gray-300 font-mono">{markets.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800/50 pb-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Correlations</span>
|
||||
<span className="text-xs text-amber-400 font-mono">{correlationCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest">Threat Level</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 ${threatStyle.bg} ${threatStyle.text} ${threatStyle.border} border ${threat?.level === 'SEVERE' || threat?.level === 'HIGH' ? 'animate-pulse' : ''}`}>
|
||||
{threat?.level || 'UNKNOWN'} {threat?.score != null ? `(${threat.score})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Threat drivers */}
|
||||
{threat?.drivers && threat.drivers.length > 0 && (
|
||||
<div className="mt-3 pt-2 border-t border-gray-800">
|
||||
<span className="text-[8px] text-gray-500 uppercase tracking-widest block mb-1">THREAT DRIVERS</span>
|
||||
{threat.drivers.slice(0, 3).map((driver, i) => (
|
||||
<p key={i} className="text-[9px] text-gray-400 leading-tight">• {driver}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-gray-800">
|
||||
<div className="w-full bg-gray-900 h-1.5 rounded-full overflow-hidden flex">
|
||||
<div className="bg-cyan-500" style={{ width: `${Math.min((threat?.score || 50), 100)}%` }}></div>
|
||||
<div className="bg-green-500 flex-1"></div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-[8px] text-gray-500 uppercase">Threat Score</span>
|
||||
<span className="text-[8px] text-gray-500 uppercase">{threat?.score ?? '—'}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Live Network Telemetry Log */}
|
||||
<LiveActivityLog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
export default function TrendingPosts() {
|
||||
return (
|
||||
<div className="border border-gray-800 bg-gray-900/10 p-3 w-64 hidden lg:block shrink-0 h-fit">
|
||||
<h3 className="text-cyan-400 font-bold mb-3 flex items-center text-xs tracking-widest uppercase border-b border-gray-800 pb-2">
|
||||
<MessageSquare size={14} className="mr-2" /> Gates
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] text-gray-500 leading-relaxed">
|
||||
<p className="text-amber-400/80 font-bold mb-1">TEST-NET ACTIVE</p>
|
||||
<p>Gates are decentralized chatrooms running on the Infonet mesh. All messages are end-to-end encrypted via Wormhole.</p>
|
||||
<p className="mt-2">Type <span className="text-green-400 font-bold">gates</span> or <span className="text-green-400 font-bold">g/</span> to browse available rooms.</p>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-gray-800">
|
||||
<p className="text-red-500 font-bold text-xs mb-1">SHADOWBROKER ADVISORY</p>
|
||||
<p className="text-[11px] text-red-400/70 leading-relaxed">
|
||||
This is a <span className="text-red-400 font-bold">testnet</span>. Treat all communications as <span className="text-red-400 font-bold">obfuscated, not encrypted</span>. Do not assume full privacy, anonymity, or end-to-end encryption. Transport security is experimental and unaudited. Use at your own risk.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const LOCATIONS = [
|
||||
{ name: 'Night City', tz: 'America/Los_Angeles', tempC: 18 },
|
||||
{ name: 'Tokyo', tz: 'Asia/Tokyo', tempC: 22 },
|
||||
{ name: 'New York', tz: 'America/New_York', tempC: 25 },
|
||||
{ name: 'London', tz: 'Europe/London', tempC: 12 },
|
||||
{ name: 'Neo Seoul', tz: 'Asia/Seoul', tempC: 19 },
|
||||
];
|
||||
|
||||
export default function WeatherWidget() {
|
||||
const [locIdx, setLocIdx] = useState(0);
|
||||
const [isCelsius, setIsCelsius] = useState(false);
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const loc = LOCATIONS[locIdx];
|
||||
const temp = isCelsius ? loc.tempC : Math.round(loc.tempC * 9/5 + 32);
|
||||
const tempUnit = isCelsius ? 'C' : 'F';
|
||||
|
||||
const timeString = time.toLocaleTimeString('en-US', { timeZone: loc.tz, hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const dateString = time.toLocaleDateString('en-US', { timeZone: loc.tz, month: 'short', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[10px] md:text-xs text-gray-400 border border-gray-800 bg-gray-900/30 px-2 py-1 shrink-0 font-mono tracking-widest uppercase whitespace-nowrap">
|
||||
<span>{dateString} {timeString}</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span
|
||||
className="cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => setLocIdx((i) => (i + 1) % LOCATIONS.length)}
|
||||
title="Change Location & Timezone"
|
||||
>
|
||||
{loc.name}
|
||||
</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span
|
||||
className="cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => setIsCelsius(!isCelsius)}
|
||||
title="Toggle C / F"
|
||||
>
|
||||
{temp}°{tempUnit}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronLeft, Briefcase, CheckCircle, Clock } from 'lucide-react';
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reward: number;
|
||||
status: 'Open' | 'In Progress' | 'Completed';
|
||||
timeLimit: string;
|
||||
}
|
||||
|
||||
const MOCK_JOBS: Job[] = [
|
||||
{
|
||||
id: 'JOB-901',
|
||||
title: 'Host Routing Node',
|
||||
description: 'Maintain 99.9% uptime for a Tier-2 mesh routing node for 24 hours. Bandwidth requirement: 10Gbps minimum.',
|
||||
reward: 300,
|
||||
status: 'Open',
|
||||
timeLimit: '24h'
|
||||
},
|
||||
{
|
||||
id: 'JOB-902',
|
||||
title: 'Find Prediction Market Source',
|
||||
description: 'Provide an unassailable, cryptographically signed news source confirming the outcome of the "Arasaka Merger" market.',
|
||||
reward: 500,
|
||||
status: 'Open',
|
||||
timeLimit: '12h'
|
||||
},
|
||||
{
|
||||
id: 'JOB-903',
|
||||
title: 'Smart Contract Audit',
|
||||
description: 'Find interesting things a blockchain might need fulfilled. Specifically looking for reentrancy vulnerabilities in the new Credits staking pool.',
|
||||
reward: 5000,
|
||||
status: 'Open',
|
||||
timeLimit: '72h'
|
||||
},
|
||||
{
|
||||
id: 'JOB-904',
|
||||
title: 'Data Courier to Sector 4',
|
||||
description: 'Physically transport an encrypted drive to a dead drop in Sector 4. High risk, high reward. No questions asked.',
|
||||
reward: 1500,
|
||||
status: 'Open',
|
||||
timeLimit: '4h'
|
||||
}
|
||||
];
|
||||
|
||||
export default function WorkView({ onBack }: { onBack: () => void }) {
|
||||
const [jobs, setJobs] = useState<Job[]>(MOCK_JOBS);
|
||||
|
||||
const handleAccept = (id: string) => {
|
||||
setJobs(jobs.map(job => job.id === id ? { ...job, status: 'In Progress' } : job));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50 mb-4"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest flex items-center">
|
||||
<Briefcase className="mr-2 text-cyan-400" />
|
||||
NETWORK WORK & BOUNTIES
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Earn Credits and Common Rep by fulfilling network contracts.</p>
|
||||
</div>
|
||||
|
||||
{/* Jobs List */}
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-4 pb-4">
|
||||
{jobs.map(job => (
|
||||
<div key={job.id} className="border border-gray-800 bg-gray-900/20 p-4 hover:border-gray-700 transition-colors">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">CONTRACT ID: {job.id}</span>
|
||||
<span className={`text-xs font-bold px-2 py-1 border ${
|
||||
job.status === 'Open' ? 'text-green-400 border-green-900/50 bg-green-900/20' :
|
||||
job.status === 'In Progress' ? 'text-cyan-400 border-cyan-900/50 bg-cyan-900/20' :
|
||||
'text-cyan-400 border-cyan-900/50 bg-cyan-900/20'
|
||||
}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg text-gray-300 font-bold mb-2">{job.title}</h2>
|
||||
<p className="text-sm text-gray-400 mb-4 leading-relaxed">{job.description}</p>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-800 pt-3 mt-2">
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-gray-300 font-bold flex items-center">
|
||||
REWARD: <span className="text-green-400 ml-2">{job.reward} CREDITS</span>
|
||||
</span>
|
||||
<span className="text-gray-500 flex items-center">
|
||||
<Clock size={14} className="mr-1" /> {job.timeLimit}
|
||||
</span>
|
||||
</div>
|
||||
{job.status === 'Open' && (
|
||||
<button
|
||||
onClick={() => handleAccept(job.id)}
|
||||
className="flex items-center px-4 py-2 bg-gray-900/50 border border-gray-800 text-cyan-400 hover:bg-gray-800 hover:text-cyan-300 transition-colors text-xs uppercase tracking-widest"
|
||||
>
|
||||
<CheckCircle size={14} className="mr-2" /> ACCEPT CONTRACT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { X, Minus } from 'lucide-react';
|
||||
import InfonetShell from './InfonetShell';
|
||||
|
||||
interface InfonetTerminalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
}
|
||||
|
||||
export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: InfonetTerminalProps) {
|
||||
/* Close on Escape */
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[400] flex items-center justify-center bg-black/60 backdrop-blur-[2px]"
|
||||
>
|
||||
{/* Window container */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative flex flex-col w-[95vw] h-[90vh] max-w-[1400px] max-h-[900px] bg-[#0a0a0a] border border-cyan-900/40 shadow-[0_0_60px_rgba(6,182,212,0.08)] crt infonet-font"
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800/60 bg-[#080808] shrink-0 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-cyan-500/60 shadow-[0_0_6px_rgba(6,182,212,0.4)]" />
|
||||
<span className="text-[10px] tracking-[0.3em] text-gray-500 uppercase">
|
||||
Infonet Sovereign Shell v0.1.1
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-600 hover:text-gray-300 transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-600 hover:text-red-400 transition-colors"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shell content — fills remaining space, scrolls internally */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<InfonetShell isOpen={isOpen} onClose={onClose} onOpenLiveGate={onOpenLiveGate} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,329 +1,451 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import ExternalImage from '@/components/ExternalImage';
|
||||
|
||||
// ─── Inline SVG legend icons (small, crisp, no external deps) ───
|
||||
const plane = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><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>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><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 airliner = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><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 M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5"/><circle cx="7" cy="12.5" r="1.2" fill="${fill}" stroke="black" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="${fill}" stroke="black" stroke-width="0.5"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><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 M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5"/><circle cx="7" cy="12.5" r="1.2" fill="${fill}" stroke="black" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="${fill}" stroke="black" stroke-width="0.5"/></svg>`;
|
||||
|
||||
const turboprop = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z"/></svg>`;
|
||||
|
||||
const bizjet = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z"/></svg>`;
|
||||
|
||||
const heli = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="${fill}" stroke-dasharray="2 2" stroke-width="1"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="${fill}" stroke-dasharray="2 2" stroke-width="1"/></svg>`;
|
||||
|
||||
const ship = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
|
||||
const triangle = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
|
||||
const circle = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
|
||||
const dot = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
|
||||
function IconImg({ svg }: { svg: string }) {
|
||||
return <img src={`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`} alt="" className="w-4 h-4 flex-shrink-0" draggable={false} />;
|
||||
return (
|
||||
<ExternalImage
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Legend data ───
|
||||
|
||||
interface LegendItem {
|
||||
svg: string;
|
||||
label: string;
|
||||
svg: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface LegendCategory {
|
||||
name: string;
|
||||
color: string;
|
||||
items: LegendItem[];
|
||||
name: string;
|
||||
color: string;
|
||||
items: LegendItem[];
|
||||
}
|
||||
|
||||
const sat = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${fill}" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="8" fill="none" stroke="${fill}" stroke-dasharray="3 3" stroke-width="0.8"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${fill}" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="8" fill="none" stroke="${fill}" stroke-dasharray="3 3" stroke-width="0.8"/></svg>`;
|
||||
|
||||
const square = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="${fill}" stroke="#000" stroke-width="1" opacity="0.6" rx="2"/></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="${fill}" stroke="#000" stroke-width="1" opacity="0.6" rx="2"/></svg>`;
|
||||
|
||||
const clusterCircle = (fill: string, stroke: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="${fill}" stroke="${stroke}" stroke-width="2" opacity="0.8"/><text x="12" y="15" text-anchor="middle" fill="white" font-size="8" font-family="monospace" font-weight="bold">5</text></svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="${fill}" stroke="${stroke}" stroke-width="2" opacity="0.8"/><text x="12" y="15" text-anchor="middle" fill="white" font-size="8" font-family="monospace" font-weight="bold">5</text></svg>`;
|
||||
|
||||
const LEGEND: LegendCategory[] = [
|
||||
{
|
||||
name: "COMMERCIAL AVIATION",
|
||||
color: "text-cyan-400 border-cyan-500/30",
|
||||
items: [
|
||||
{ svg: airliner("cyan"), label: "Airliner (swept wings)" },
|
||||
{ svg: turboprop("cyan"), label: "Turboprop (straight wings)" },
|
||||
{ svg: heli("cyan"), label: "Helicopter (rotor disc)" },
|
||||
{ svg: airliner("#555"), label: "Grounded / Parked (grey)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "PRIVATE AVIATION",
|
||||
color: "text-orange-400 border-orange-500/30",
|
||||
items: [
|
||||
{ svg: airliner("#FF8C00"), label: "Private Flight — Airliner" },
|
||||
{ svg: turboprop("#FF8C00"), label: "Private Flight — Turboprop" },
|
||||
{ svg: heli("#FF8C00"), label: "Private Flight — Helicopter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "PRIVATE JETS",
|
||||
color: "text-purple-400 border-purple-500/30",
|
||||
items: [
|
||||
{ svg: bizjet("#9B59B6"), label: "Private Jet — Bizjet" },
|
||||
{ svg: airliner("#9B59B6"), label: "Private Jet — Airliner" },
|
||||
{ svg: turboprop("#9B59B6"), label: "Private Jet — Turboprop" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MILITARY AVIATION",
|
||||
color: "text-yellow-400 border-yellow-500/30",
|
||||
items: [
|
||||
{ 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 (live ADS-B)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "TRACKED AIRCRAFT (ALERT)",
|
||||
color: "text-pink-400 border-pink-500/30",
|
||||
items: [
|
||||
{ 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)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SATELLITES",
|
||||
color: "text-sky-400 border-sky-500/30",
|
||||
items: [
|
||||
{ svg: sat("#ff3333"), label: "Military Recon / SAR (red)" },
|
||||
{ svg: sat("#00e5ff"), label: "Synthetic Aperture Radar (cyan)" },
|
||||
{ svg: sat("#ffffff"), label: "Signals Intelligence / ELINT (white)" },
|
||||
{ svg: sat("#4488ff"), label: "Navigation — GPS / GLONASS / BeiDou (blue)" },
|
||||
{ svg: sat("#ff00ff"), label: "Early Warning — Missile Detection (magenta)" },
|
||||
{ svg: sat("#44ff44"), label: "Commercial Imaging (green)" },
|
||||
{ svg: sat("#ffdd00"), label: "Space Station — ISS / Tiangong (gold)" },
|
||||
{ svg: sat("#aaaaaa"), label: "Unclassified / Other (grey)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MARITIME",
|
||||
color: "text-blue-400 border-blue-500/30",
|
||||
items: [
|
||||
{ svg: ship("gray"), label: "Civilian / Unknown Vessel" },
|
||||
{ svg: ship("yellow"), label: "Tanker" },
|
||||
{ svg: ship("#ff2222"), label: "Military Vessel" },
|
||||
{ svg: ship("#3b82f6"), label: "Cargo Ship" },
|
||||
{ svg: ship("white"), label: "Cruise / Passenger" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><polygon points="3,21 21,21 20,4 16,4 16,3 12,3 12,4 4,4" /><rect x="15" y="6" width="3" height="10" /></svg>`, label: "Aircraft Carrier" },
|
||||
{ svg: clusterCircle("#3b82f6", "#1d4ed8"), label: "Ship Cluster (count inside)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "GEOPHYSICAL",
|
||||
color: "text-orange-400 border-orange-500/30",
|
||||
items: [
|
||||
{ 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)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "INCIDENTS & INTELLIGENCE",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
items: [
|
||||
{ svg: triangle("#ffaa00"), label: "GDELT / LiveUA event (yellow)" },
|
||||
{ svg: triangle("#ff0000"), label: "Violent / Kinetic event (red)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>`, label: "Threat Alert (news cluster)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "NEWS & OSINT",
|
||||
color: "text-cyan-400 border-cyan-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12" viewBox="0 0 40 24"><rect x="1" y="1" width="38" height="22" rx="3" fill="#111" stroke="cyan" stroke-width="1"/><text x="6" y="10" fill="red" font-size="6" font-family="monospace">!! ALERT</text><text x="6" y="17" fill="white" font-size="4" font-family="monospace">News Headline</text></svg>`, label: "Geolocated news alert box" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "GPS JAMMING / INTERFERENCE",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
items: [
|
||||
{ svg: square("#ff0040"), label: "High severity (>75% aircraft degraded)" },
|
||||
{ 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.35" rx="2"/></svg>`, label: "Medium severity (50-75% degraded)" },
|
||||
{ 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",
|
||||
items: [
|
||||
{ svg: dot("#22c55e"), label: "Individual CCTV camera (green dot)" },
|
||||
{ svg: clusterCircle("#22c55e", "#16a34a"), label: "Camera cluster (count inside)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="cyan" stroke-width="2"><path d="M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-.894.553H5.652a1 1 0 0 1-.894-.553L2.724 13.447A1 1 0 0 1 3.618 12h3.632M14 12V8a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a4 4 0 1 0 8 0Z" /></svg>`, label: "CCTV icon (detail view)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "OVERLAYS",
|
||||
color: "text-gray-400 border-gray-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect width="24" height="24" fill="#0a0e1a" opacity="0.4"/><circle cx="12" cy="12" r="4" fill="#ffd700"/></svg>`, label: "Day / Night terminator" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><line x1="4" y1="4" x2="20" y2="4" stroke="red" stroke-width="2"/><line x1="4" y1="8" x2="20" y2="8" stroke="#ff6600" stroke-width="2"/></svg>`, label: "Ukraine frontline" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'COMMERCIAL AVIATION',
|
||||
color: 'text-cyan-600 border-cyan-700/30',
|
||||
items: [
|
||||
{ svg: airliner('#0891b2'), label: 'Airliner (dim cyan — baseline)' },
|
||||
{ svg: turboprop('#0891b2'), label: 'Turboprop (dim cyan)' },
|
||||
{ svg: heli('#0891b2'), label: 'Helicopter (dim cyan)' },
|
||||
{ svg: airliner('#555'), label: 'Grounded / Parked (grey)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'PRIVATE / UNKNOWN AVIATION',
|
||||
color: 'text-purple-400 border-purple-500/30',
|
||||
items: [
|
||||
{ svg: airliner('#9B59B6'), label: 'Private Flight — Airliner (purple)' },
|
||||
{ svg: turboprop('#9B59B6'), label: 'Private Flight — Turboprop' },
|
||||
{ svg: bizjet('#9B59B6'), label: 'Private Jet — Bizjet' },
|
||||
{ svg: heli('#9B59B6'), label: 'Private / Unknown — Helicopter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'MILITARY AVIATION',
|
||||
color: 'text-amber-400 border-amber-500/30',
|
||||
items: [
|
||||
{ svg: airliner('#f59e0b'), label: 'Military — Standard (amber)' },
|
||||
{ svg: plane('#f59e0b'), label: 'Fighter / Interceptor (amber)' },
|
||||
{ svg: heli('#f59e0b'), label: 'Military — Helicopter (amber)' },
|
||||
{
|
||||
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: '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)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SATELLITES',
|
||||
color: 'text-sky-400 border-sky-500/30',
|
||||
items: [
|
||||
{ svg: sat('#ff3333'), label: 'Military Recon / SAR (red)' },
|
||||
{ svg: sat('#00e5ff'), label: 'Synthetic Aperture Radar (cyan)' },
|
||||
{ svg: sat('#ffffff'), label: 'Signals Intelligence / ELINT (white)' },
|
||||
{ svg: sat('#4488ff'), label: 'Navigation — GPS / GLONASS / BeiDou (blue)' },
|
||||
{ svg: sat('#ff00ff'), label: 'Early Warning — Missile Detection (magenta)' },
|
||||
{ svg: sat('#44ff44'), label: 'Commercial Imaging (green)' },
|
||||
{ svg: sat('#ffdd00'), label: 'Space Station — ISS / Tiangong (gold)' },
|
||||
{ svg: sat('#aaaaaa'), label: 'Unclassified / Other (grey)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'MARITIME',
|
||||
color: 'text-blue-400 border-blue-500/30',
|
||||
items: [
|
||||
{ svg: ship('#ff2222'), label: 'Cargo / Tanker (red)' },
|
||||
{ svg: ship('#f59e0b'), label: 'Military Vessel (amber)' },
|
||||
{ svg: ship('white'), label: 'Cruise / Passenger / Yacht (white)' },
|
||||
{ svg: ship('#FF69B4'), label: 'Tracked Yacht (pink)' },
|
||||
{ svg: ship('#3b82f6'), label: 'Civilian / Unknown (blue)' },
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><polygon points="3,21 21,21 20,4 16,4 16,3 12,3 12,4 4,4" /><rect x="15" y="6" width="3" height="10" /></svg>`,
|
||||
label: 'Aircraft Carrier (orange)',
|
||||
},
|
||||
{ svg: clusterCircle('#3b82f6', '#1d4ed8'), label: 'Ship Cluster (count inside)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GEOPHYSICAL',
|
||||
color: 'text-orange-400 border-orange-500/30',
|
||||
items: [{ 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)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'INCIDENTS & INTELLIGENCE',
|
||||
color: 'text-red-400 border-red-500/30',
|
||||
items: [
|
||||
{ svg: triangle('#ffaa00'), label: 'GDELT / LiveUA event (yellow)' },
|
||||
{ svg: triangle('#ff0000'), label: 'Violent / Kinetic event (red)' },
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>`,
|
||||
label: 'Threat Alert (news cluster)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'NEWS & OSINT',
|
||||
color: 'text-cyan-400 border-cyan-500/30',
|
||||
items: [
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12" viewBox="0 0 40 24"><rect x="1" y="1" width="38" height="22" rx="3" fill="#111" stroke="cyan" stroke-width="1"/><text x="6" y="10" fill="red" font-size="6" font-family="monospace">!! ALERT</text><text x="6" y="17" fill="white" font-size="4" font-family="monospace">News Headline</text></svg>`,
|
||||
label: 'Geolocated news alert box',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GPS JAMMING / INTERFERENCE',
|
||||
color: 'text-red-400 border-red-500/30',
|
||||
items: [
|
||||
{ svg: square('#ff0040'), label: 'High severity (>75% aircraft degraded)' },
|
||||
{
|
||||
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.35" rx="2"/></svg>`,
|
||||
label: 'Medium severity (50-75% degraded)',
|
||||
},
|
||||
{
|
||||
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',
|
||||
items: [
|
||||
{ svg: dot('#22c55e'), label: 'Individual CCTV camera (green dot)' },
|
||||
{ svg: clusterCircle('#22c55e', '#16a34a'), label: 'Camera cluster (count inside)' },
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="cyan" stroke-width="2"><path d="M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-.894.553H5.652a1 1 0 0 1-.894-.553L2.724 13.447A1 1 0 0 1 3.618 12h3.632M14 12V8a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a4 4 0 1 0 8 0Z" /></svg>`,
|
||||
label: 'CCTV icon (detail view)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SELECTION HUD',
|
||||
color: 'text-cyan-400 border-cyan-500/30',
|
||||
items: [
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><line x1="4" y1="12" x2="20" y2="12" stroke="#22d3ee" stroke-width="2" stroke-dasharray="3 3" opacity="0.5"/><circle cx="20" cy="12" r="2.5" fill="#22d3ee" opacity="0.4"/></svg>`,
|
||||
label: 'Predictive vector (~5 min ahead)',
|
||||
},
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="#22d3ee" stroke-width="1" stroke-dasharray="4 3" opacity="0.2"/><circle cx="12" cy="12" r="5" fill="none" stroke="#22d3ee" stroke-width="1" stroke-dasharray="4 3" opacity="0.2"/></svg>`,
|
||||
label: 'Proximity rings (10 / 50 / 100nm)',
|
||||
},
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><line x1="4" y1="12" x2="20" y2="12" stroke="#22d3ee" stroke-width="2" opacity="0.6"/></svg>`,
|
||||
label: 'Flight trail (position history)',
|
||||
},
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><line x1="4" y1="12" x2="20" y2="12" stroke="cyan" stroke-width="2" opacity="0.8"/><circle cx="4" cy="12" r="2" fill="lime"/><circle cx="20" cy="12" r="2" fill="red"/></svg>`,
|
||||
label: 'Active route (origin → dest)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SIGINT GRID',
|
||||
color: 'text-emerald-400 border-emerald-500/30',
|
||||
items: [
|
||||
{ svg: dot('#22c55e'), label: 'APRS-IS station (green, isnād 0.7)' },
|
||||
{ svg: triangle('#22c55e'), label: 'Meshtastic node (green triangle, isnād 0.5)' },
|
||||
{ svg: dot('#f59e0b'), label: 'JS8Call station (amber, isnād 0.9)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ORACLE SERVICE',
|
||||
color: 'text-cyan-400 border-cyan-500/30',
|
||||
items: [
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12" viewBox="0 0 40 16"><rect x="1" y="1" width="38" height="14" rx="2" fill="#111" stroke="#22d3ee" stroke-width="1"/><text x="4" y="10" fill="#22d3ee" font-size="7" font-family="monospace" font-weight="bold">ORCL:7.2</text></svg>`,
|
||||
label: 'Oracle score badge (weighted risk)',
|
||||
},
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12" viewBox="0 0 40 16"><rect x="1" y="1" width="38" height="14" rx="2" fill="#111" stroke="#a855f7" stroke-width="1"/><text x="4" y="10" fill="#a855f7" font-size="7" font-family="monospace" font-weight="bold">MKT:23%</text></svg>`,
|
||||
label: 'Prediction market consensus',
|
||||
},
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><text x="6" y="16" fill="#22c55e" font-size="14" font-family="monospace" font-weight="bold">▲</text></svg>`,
|
||||
label: 'Sentiment: ▲ positive / ▼ negative / — neutral',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'OVERLAYS',
|
||||
color: 'text-gray-400 border-gray-500/30',
|
||||
items: [
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect width="24" height="24" fill="#0a0e1a" opacity="0.4"/><circle cx="12" cy="12" r="4" fill="#ffd700"/></svg>`,
|
||||
label: 'Day / Night terminator',
|
||||
},
|
||||
{
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><line x1="4" y1="4" x2="20" y2="4" stroke="red" stroke-width="2"/><line x1="4" y1="8" x2="20" y2="8" stroke="#ff6600" stroke-width="2"/></svg>`,
|
||||
label: 'Ukraine frontline',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const MapLegend = React.memo(function MapLegend({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (name: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggle = (name: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Legend Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
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-[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)]"
|
||||
{/* Legend Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
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-[var(--bg-secondary)]/95 backdrop-blur-sm border border-cyan-900/50 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-[var(--border-primary)]/80 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 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"
|
||||
>
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M12 3v12" />
|
||||
<path d="m8 11 4 4 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<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 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>
|
||||
|
||||
{/* Legend Content */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
|
||||
{LEGEND.map((cat) => {
|
||||
const isCollapsed = collapsed.has(cat.name);
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className="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-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
||||
>
|
||||
{/* Header */}
|
||||
<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">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M12 3v12" />
|
||||
<path d="m8 11 4 4 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<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-[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>
|
||||
<span
|
||||
className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 border ${cat.color}`}
|
||||
>
|
||||
{cat.name}
|
||||
</span>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown size={12} className="text-[var(--text-muted)]" />
|
||||
) : (
|
||||
<ChevronUp size={12} className="text-[var(--text-muted)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Legend Content */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
|
||||
{LEGEND.map((cat) => {
|
||||
const isCollapsed = collapsed.has(cat.name);
|
||||
return (
|
||||
<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-[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-[var(--text-muted)]" /> : <ChevronUp size={12} className="text-[var(--text-muted)]" />}
|
||||
</button>
|
||||
{/* Items */}
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
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-[var(--bg-secondary)]/30 transition-colors"
|
||||
>
|
||||
<IconImg svg={item.svg} />
|
||||
<span className="text-[11px] text-[var(--text-secondary)] font-mono">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
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-[var(--bg-secondary)]/30 transition-colors">
|
||||
<IconImg svg={item.svg} />
|
||||
<span className="text-[11px] text-[var(--text-secondary)] font-mono">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<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>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
{/* Footer */}
|
||||
<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>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default MapLegend;
|
||||
|
||||
+5758
-2485
File diff suppressed because it is too large
Load Diff
@@ -1,89 +1,381 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, Droplet, ChevronDown, ChevronUp, Globe } from 'lucide-react';
|
||||
import type { DashboardData } from "@/types/dashboard";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
TrendingUp,
|
||||
Landmark,
|
||||
UserCheck,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import type { DashboardData, StockTicker } from '@/types/dashboard';
|
||||
import type { CongressTrade, InsiderTransaction } from '@/types/unusualWhales';
|
||||
import { fetchUWStatus, fetchCongressTrades, fetchInsiderTransactions } from '@/lib/uwClient';
|
||||
|
||||
const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: DashboardData }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
type Tab = 'tickers' | 'congress' | 'insider';
|
||||
|
||||
const stocks = data?.stocks || {};
|
||||
const oil = data?.oil || {};
|
||||
const TAB_CONFIG: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'tickers', label: 'TICKERS', icon: <TrendingUp size={10} /> },
|
||||
{ key: 'congress', label: 'CONGRESS', icon: <Landmark size={10} /> },
|
||||
{ key: 'insider', label: 'INSIDER', icon: <UserCheck size={10} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
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-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={12} className="text-cyan-500" />
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function chamberBadge(chamber: string) {
|
||||
const c = chamber?.toLowerCase();
|
||||
if (c === 'senator' || c === 'senate') return 'S';
|
||||
if (c === 'representative' || c === 'house') return 'H';
|
||||
return c?.charAt(0)?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function txColor(tx: string) {
|
||||
const t = tx?.toLowerCase() || '';
|
||||
if (t.includes('purchase') || t.includes('buy')) return 'text-green-400';
|
||||
if (t.includes('sale') || t.includes('sell')) return 'text-red-400';
|
||||
return 'text-yellow-400';
|
||||
}
|
||||
|
||||
function insiderCodeLabel(code: string) {
|
||||
const map: Record<string, string> = {
|
||||
P: 'Purchase', S: 'Sale', A: 'Grant', M: 'Exercise',
|
||||
F: 'Tax', G: 'Gift', C: 'Conversion', X: 'Expiration',
|
||||
};
|
||||
return map[code?.toUpperCase()] || code || '—';
|
||||
}
|
||||
|
||||
// ── Tab: Tickers ────────────────────────────────────────────────────────────
|
||||
|
||||
const CRYPTO_LABELS = new Set(['BTC', 'ETH']);
|
||||
|
||||
function TickerRow({ ticker, info }: { ticker: string; info: StockTicker }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm">
|
||||
<span className="font-bold text-cyan-300 text-[10px]">[{ticker}]</span>
|
||||
<div className="flex items-center gap-3 text-right">
|
||||
<span className="text-[var(--text-primary)] font-bold text-xs">
|
||||
${(info.price ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 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 ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TickersTab({ stocks, oil }: { stocks: Record<string, StockTicker>; oil: Record<string, StockTicker> }) {
|
||||
const defenseEntries = Object.entries(stocks).filter(([k]) => !CRYPTO_LABELS.has(k));
|
||||
const cryptoEntries = Object.entries(stocks).filter(([k]) => CRYPTO_LABELS.has(k));
|
||||
const hasDefense = defenseEntries.length > 0;
|
||||
const hasCrypto = cryptoEntries.length > 0;
|
||||
const hasOil = Object.keys(oil).length > 0;
|
||||
if (!hasDefense && !hasCrypto && !hasOil)
|
||||
return <div className="text-[var(--text-muted)] text-[10px] py-4 text-center">Waiting for market data...</div>;
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{hasCrypto && (
|
||||
<div>
|
||||
<h3 className="text-[9px] font-bold tracking-widest text-orange-400 mb-1.5">CRYPTO</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{cryptoEntries.map(([ticker, info]) => (
|
||||
<TickerRow key={ticker} ticker={ticker} info={info} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasDefense && (
|
||||
<div>
|
||||
<h3 className="text-[9px] font-bold tracking-widest text-cyan-400 mb-1.5">DEFENSE SECTOR</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{defenseEntries.map(([ticker, info]) => (
|
||||
<TickerRow key={ticker} ticker={ticker} info={info} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasOil && (
|
||||
<div>
|
||||
<h3 className="text-[9px] font-bold tracking-widest text-cyan-400 mb-1.5">COMMODITIES</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.entries(oil).map(([name, info]) => (
|
||||
<div key={name} className="flex flex-col border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm">
|
||||
<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-[var(--text-primary)] font-bold text-[11px]">${(info.price ?? 0).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 ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab: Congress ───────────────────────────────────────────────────────────
|
||||
|
||||
function CongressTab({ trades }: { trades: CongressTrade[] }) {
|
||||
if (!trades.length)
|
||||
return <div className="text-[var(--text-muted)] text-[10px] py-4 text-center">No recent congress trades</div>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{trades.slice(0, 20).map((t, i) => (
|
||||
<div key={i} className="border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[9px] font-bold text-cyan-300 bg-cyan-900/40 px-1 rounded flex-shrink-0">
|
||||
{chamberBadge(t.chamber)}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-primary)] truncate font-medium">
|
||||
{t.politician_name}
|
||||
</span>
|
||||
</div>
|
||||
{t.ticker && (
|
||||
<span className="text-[10px] font-bold text-cyan-400 flex-shrink-0">{t.ticker}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className={`text-[9px] ${txColor(t.transaction_type || '')}`}>
|
||||
{t.transaction_type || '—'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{t.amount_range && (
|
||||
<span className="text-[9px] text-[var(--text-muted)]">{t.amount_range}</span>
|
||||
)}
|
||||
{t.filing_date && (
|
||||
<span className="text-[9px] text-[var(--text-muted)]">{t.filing_date}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t.asset_name && t.asset_name !== t.ticker && (
|
||||
<div className="text-[8px] text-[var(--text-muted)]/70 truncate mt-0.5">{t.asset_name}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
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-[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>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{Object.entries(stocks).map(([ticker, info]: [string, 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-[var(--text-primary)] font-bold text-xs">${(info.price ?? 0).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 ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
// ── Tab: Insider ────────────────────────────────────────────────────────────
|
||||
|
||||
<div>
|
||||
<h2 className="text-xs font-bold tracking-widest text-cyan-400 flex items-center gap-2 mb-2">
|
||||
<Droplet className="text-cyan-500" size={14} /> COMMODITY INDEX
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{Object.entries(oil).map(([name, info]: [string, 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-[var(--text-primary)] font-bold text-[11px]">${(info.price ?? 0).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 ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
function InsiderTab({ transactions }: { transactions: InsiderTransaction[] }) {
|
||||
if (!transactions.length)
|
||||
return <div className="text-[var(--text-muted)] text-[10px] py-4 text-center">No recent insider transactions</div>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{transactions.slice(0, 20).map((t, i) => {
|
||||
const isBuy = t.transaction_code === 'P';
|
||||
const isSell = t.transaction_code === 'S';
|
||||
return (
|
||||
<div key={i} className="border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] text-[var(--text-primary)] truncate font-medium">{t.name}</span>
|
||||
<span className="text-[10px] font-bold text-cyan-400 flex-shrink-0">{t.ticker}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className={`text-[9px] font-bold ${isBuy ? 'text-green-400' : isSell ? 'text-red-400' : 'text-yellow-400'}`}>
|
||||
{insiderCodeLabel(t.transaction_code || '')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{t.change !== 0 && (
|
||||
<span className={`text-[9px] ${t.change > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{t.change > 0 ? '+' : ''}{t.change.toLocaleString()} shares
|
||||
</span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
{t.transaction_price > 0 && (
|
||||
<span className="text-[9px] text-[var(--text-muted)]">${t.transaction_price.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t.filing_date && (
|
||||
<div className="text-[8px] text-[var(--text-muted)]/70 mt-0.5">{t.filing_date}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface MarketsPanelProps {
|
||||
data: DashboardData;
|
||||
focused?: boolean;
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
}
|
||||
|
||||
const MarketsPanel = React.memo(function MarketsPanel({ data, focused, onFocusChange }: MarketsPanelProps) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('tickers');
|
||||
const [finnhubConfigured, setFinnhubConfigured] = useState<boolean | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Local overlay for on-demand fetches
|
||||
const [localCongress, setLocalCongress] = useState<CongressTrade[] | null>(null);
|
||||
const [localInsider, setLocalInsider] = useState<InsiderTransaction[] | null>(null);
|
||||
|
||||
// Check Finnhub status
|
||||
useEffect(() => {
|
||||
fetchUWStatus()
|
||||
.then((s) => setFinnhubConfigured(s.configured))
|
||||
.catch(() => setFinnhubConfigured(false));
|
||||
}, []);
|
||||
|
||||
// Data sources: background-polled + local overlay
|
||||
const stocks = data?.stocks || {};
|
||||
const oil = data?.oil || {};
|
||||
const uw = data?.unusual_whales;
|
||||
const congressTrades = localCongress ?? uw?.congress_trades ?? [];
|
||||
const insiderTxns = localInsider ?? uw?.insider_transactions ?? [];
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (refreshing) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [c, ins] = await Promise.all([
|
||||
fetchCongressTrades().catch(() => null),
|
||||
fetchInsiderTransactions().catch(() => null),
|
||||
]);
|
||||
if (c?.trades) setLocalCongress(c.trades);
|
||||
if (ins?.transactions) setLocalInsider(ins.transactions);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refreshing]);
|
||||
|
||||
// Determine if Finnhub tabs should show
|
||||
const hasFinnhub = finnhubConfigured === true;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="w-full bg-[var(--bg-primary)]/40 backdrop-blur-sm border border-[var(--border-primary)] z-10 flex flex-col font-mono text-sm pointer-events-auto flex-shrink-0"
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
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={() => {
|
||||
const next = !isMinimized;
|
||||
setIsMinimized(next);
|
||||
onFocusChange?.(!next);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp size={12} className="text-cyan-500" />
|
||||
<span className="text-[12px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
GLOBAL MARKETS
|
||||
</span>
|
||||
{hasFinnhub && (
|
||||
<span className="text-[8px] text-green-500 bg-green-900/30 px-1 rounded">FINNHUB</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className={`overflow-y-auto styled-scrollbar flex flex-col ${focused ? 'max-h-[calc(100vh-180px)]' : 'max-h-[450px]'}`}
|
||||
>
|
||||
{hasFinnhub ? (
|
||||
<>
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-[var(--border-primary)]/50">
|
||||
{TAB_CONFIG.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-2 text-[9px] tracking-wider transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'text-cyan-400 border-b border-cyan-400 bg-cyan-950/20'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Refresh bar (congress/insider tabs only) */}
|
||||
{activeTab !== 'tickers' && (
|
||||
<div className="flex justify-end px-3 pt-2 pb-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRefresh(); }}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 text-[9px] text-[var(--text-muted)] hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw size={10} className={refreshing ? 'animate-spin' : ''} />
|
||||
{refreshing ? 'FETCHING...' : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="p-3 pt-1">
|
||||
{activeTab === 'tickers' && <TickersTab stocks={stocks} oil={oil} />}
|
||||
{activeTab === 'congress' && <CongressTab trades={congressTrades} />}
|
||||
{activeTab === 'insider' && <InsiderTab transactions={insiderTxns} />}
|
||||
</div>
|
||||
|
||||
{/* Attribution */}
|
||||
<div className="px-3 pb-2">
|
||||
<p className="text-[8px] text-[var(--text-muted)]/60 text-center">
|
||||
Data from Finnhub
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* No Finnhub key — show stocks/oil only (yfinance fallback) + setup hint */
|
||||
<div className="flex flex-col">
|
||||
<div className="p-3">
|
||||
<TickersTab stocks={stocks} oil={oil} />
|
||||
</div>
|
||||
{finnhubConfigured === false && (
|
||||
<div className="flex flex-col items-center gap-2 px-3 pb-3 border-t border-[var(--border-primary)]/30 pt-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Settings size={10} className="text-[var(--text-muted)]" />
|
||||
<p className="text-[9px] text-[var(--text-muted)]">
|
||||
Add <span className="text-cyan-400">FINNHUB_API_KEY</span> for congress trades & insider data
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://finnhub.io/register"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-[8px] text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
Free API Key <ExternalLink size={8} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default MarketsPanel;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,45 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, Clock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { AlertTriangle, Clock, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import WikiImage from '@/components/WikiImage';
|
||||
import type { DashboardData, SelectedEntity, RegionDossier } from "@/types/dashboard";
|
||||
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import type { SelectedEntity, RegionDossier, FimiData } from "@/types/dashboard";
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import { lookupShodanHost } from '@/lib/shodanClient';
|
||||
import type { ShodanHost } from '@/types/shodan';
|
||||
|
||||
// Format time from pubish string "Tue, 24 Feb 2026 15:30:00 GMT" to "15:30"
|
||||
function formatTime(pubDate: string) {
|
||||
@@ -155,9 +124,15 @@ const VESSEL_TYPE_WIKI: Record<string, string> = {
|
||||
'military_vessel': 'https://en.wikipedia.org/wiki/Warship',
|
||||
};
|
||||
|
||||
function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoading }: { data: DashboardData, selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean }) {
|
||||
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number) => void }) {
|
||||
const data = useDataKeys([
|
||||
'news', 'fimi', 'commercial_flights', 'private_flights', 'private_jets',
|
||||
'military_flights', 'tracked_flights', 'ships', 'gdelt', 'liveuamap',
|
||||
'airports', 'last_updated', 'threat_level',
|
||||
] as const);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [expandedIndexes, setExpandedIndexes] = useState<number[]>([]);
|
||||
const [fimiExpanded, setFimiExpanded] = useState(false);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Intentionally omitting map click triggers for expanding
|
||||
@@ -172,6 +147,15 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
}
|
||||
|
||||
const news = data?.news || [];
|
||||
const fimi: FimiData | undefined = data?.fimi;
|
||||
|
||||
// Cross-reference: check if a news article title matches any FIMI disinfo keywords
|
||||
const fimiKeywords = useMemo(() => fimi?.disinfo_keywords || [], [fimi?.disinfo_keywords]);
|
||||
const checkDisinfoLinked = useCallback((title: string): boolean => {
|
||||
if (fimiKeywords.length === 0) return false;
|
||||
const titleLower = title.toLowerCase();
|
||||
return fimiKeywords.some(kw => titleLower.includes(kw));
|
||||
}, [fimiKeywords]);
|
||||
|
||||
// Determine the selected flight's model for Wikipedia thumbnail lookup
|
||||
// (must call hook unconditionally — React rules of hooks)
|
||||
@@ -187,6 +171,45 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
return flight?.model;
|
||||
})();
|
||||
const { imgUrl: aircraftImgUrl, wikiUrl: aircraftWikiUrl, loading: aircraftImgLoading } = useAircraftImage(selectedFlightModel);
|
||||
const [shodanDetail, setShodanDetail] = useState<ShodanHost | null>(null);
|
||||
const [shodanLoading, setShodanLoading] = useState(false);
|
||||
const [shodanError, setShodanError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const host = (selectedEntity?.extra || {}) as Record<string, any>;
|
||||
const ip = selectedEntity?.type === 'shodan_host' ? String(host.ip || '').trim() : '';
|
||||
if (!ip) {
|
||||
setShodanDetail(null);
|
||||
setShodanLoading(false);
|
||||
setShodanError(null);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(host.services) && host.services.length > 0) {
|
||||
setShodanDetail(host as unknown as ShodanHost);
|
||||
setShodanLoading(false);
|
||||
setShodanError(null);
|
||||
return;
|
||||
}
|
||||
setShodanLoading(true);
|
||||
setShodanError(null);
|
||||
lookupShodanHost(ip)
|
||||
.then((resp) => {
|
||||
if (cancelled) return;
|
||||
setShodanDetail(resp.host);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setShodanError(err instanceof Error ? err.message : 'Failed to load host detail');
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setShodanLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedEntity]);
|
||||
|
||||
// Region Dossier (right-click intelligence)
|
||||
if (selectedEntity?.type === 'region_dossier') {
|
||||
@@ -196,7 +219,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-emerald-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,255,128,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/60 backdrop-blur-sm border border-emerald-800 flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,255,128,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<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>
|
||||
@@ -206,10 +229,15 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
</div>
|
||||
{regionDossierLoading ? (
|
||||
<div className="p-6 flex items-center justify-center">
|
||||
<span className="text-emerald-400 text-[10px] font-mono animate-pulse tracking-widest">COMPILING INTELLIGENCE...</span>
|
||||
<span className="text-emerald-400 text-[10px] font-mono tracking-widest">COMPILING INTELLIGENCE...</span>
|
||||
</div>
|
||||
) : d && !d.error ? (
|
||||
<div className="p-3 flex flex-col gap-1.5 max-h-[500px] overflow-y-auto styled-scrollbar text-[10px]">
|
||||
{d.warning && (
|
||||
<div className="mb-2 p-2 bg-amber-950/40 border border-amber-800/50 text-[9px] text-amber-300 leading-relaxed">
|
||||
{d.warning}
|
||||
</div>
|
||||
)}
|
||||
{/* 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-[var(--text-muted)]">COUNTRY</span><span className="text-[var(--text-primary)] font-bold">{d.country?.name}</span></div>
|
||||
@@ -237,7 +265,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
{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-[var(--text-secondary)] leading-relaxed">
|
||||
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 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>
|
||||
@@ -256,6 +284,103 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedEntity?.type === 'shodan_host') {
|
||||
const baseHost = (selectedEntity.extra || {}) as Record<string, any>;
|
||||
const host = (shodanDetail || baseHost) as Record<string, any>;
|
||||
const portLabel = host.port ? `${host.ip}:${host.port}` : (host.ip || selectedEntity.name || 'UNKNOWN HOST');
|
||||
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-sm border border-green-800 flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(34,197,94,0.16)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-green-500/30 bg-green-950/30 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-green-400 flex items-center gap-2">
|
||||
SHODAN HOST DOSSIER
|
||||
</h2>
|
||||
<span className="text-[10px] text-green-300 font-mono">{portLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-2 text-[10px]">
|
||||
<div className="text-[9px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1">
|
||||
ATTRIBUTION
|
||||
</div>
|
||||
<div className="text-green-300/90">
|
||||
Data from Shodan · Operator-supplied API key · Local session overlay
|
||||
</div>
|
||||
{shodanLoading && (
|
||||
<div className="mt-2 text-green-500/80">Loading full host detail...</div>
|
||||
)}
|
||||
{shodanError && (
|
||||
<div className="mt-2 border border-red-900/40 bg-red-950/20 p-2 text-red-300">
|
||||
{shodanError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[9px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
|
||||
HOST
|
||||
</div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">IP</span><span className="text-green-300 font-bold">{host.ip || 'UNKNOWN'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">PORT</span><span className="text-[var(--text-primary)]">{host.port || host.ports?.[0] || 'UNKNOWN'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">ORG</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.org || 'UNKNOWN'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">ASN</span><span className="text-[var(--text-primary)]">{host.asn || 'UNKNOWN'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">ISP</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.isp || 'UNKNOWN'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">OS</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.os || 'UNKNOWN'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">PRODUCT</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.product || host.transport || 'UNKNOWN'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">SEEN</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.timestamp || host.services?.[0]?.timestamp || 'UNKNOWN'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">LOCATION</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.location_label || host.country_name || 'UNMAPPED'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">COORDS</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.lat != null && host.lng != null ? `${Number(host.lat).toFixed(4)}, ${Number(host.lng).toFixed(4)}` : 'UNMAPPED'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">HOSTNAMES</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.hostnames?.length ? host.hostnames.join(', ') : 'NONE'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">DOMAINS</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.domains?.length ? host.domains.join(', ') : 'NONE'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">TAGS</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.tags?.length ? host.tags.join(', ') : 'NONE'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">VULNS</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.vulns?.length ? host.vulns.join(', ') : 'NONE'}</span></div>
|
||||
{Array.isArray(host.ports) && host.ports.length > 0 && (
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">ALL PORTS</span><span className="text-[var(--text-primary)] text-right max-w-[190px]">{host.ports.slice(0, 20).join(', ')}</span></div>
|
||||
)}
|
||||
{Array.isArray(host.services) && host.services.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
|
||||
SERVICES
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{host.services.slice(0, 8).map((service: Record<string, any>, idx: number) => (
|
||||
<div key={`${service.port || 'svc'}-${idx}`} className="border border-green-900/40 bg-black/40 p-2">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-green-300 font-bold">
|
||||
{service.port || '?'} / {service.transport || 'tcp'}
|
||||
</span>
|
||||
<span className="text-[var(--text-muted)]">{service.timestamp || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[var(--text-primary)]">
|
||||
{service.product || 'Unknown service'}
|
||||
</div>
|
||||
{service.tags?.length > 0 && (
|
||||
<div className="mt-1 text-[9px] text-green-500/80">
|
||||
TAGS: {service.tags.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{service.banner_excerpt && (
|
||||
<div className="mt-1 text-[9px] text-green-300/90 leading-relaxed">
|
||||
{service.banner_excerpt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{host.data_snippet && (
|
||||
<div className="mt-2 border border-green-900/50 bg-black/50 p-2 text-[9px] text-green-300/90 leading-relaxed">
|
||||
<span className="text-green-400 font-bold">>_ BANNER: </span>
|
||||
{host.data_snippet}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedEntity?.type === 'tracked_flight') {
|
||||
const flight = data?.tracked_flights?.find((f: any) => f.icao24 === selectedEntity.id);
|
||||
if (flight) {
|
||||
@@ -294,7 +419,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
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' || 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`}
|
||||
className={`w-full bg-black/60 backdrop-blur-sm 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)]'} 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`}>
|
||||
@@ -309,13 +434,14 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
{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)}`;
|
||||
const operatorHref = flight.alert_link || wikiHref;
|
||||
return (
|
||||
<a
|
||||
href={wikiHref}
|
||||
href={operatorHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`}
|
||||
title={`Search Wikipedia for ${flight.alert_operator}`}
|
||||
title={flight.alert_link ? `View reference for ${flight.alert_operator}` : `Search Wikipedia for ${flight.alert_operator}`}
|
||||
>
|
||||
{flight.alert_operator}
|
||||
</a>
|
||||
@@ -346,7 +472,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<img
|
||||
src={aircraftImgUrl}
|
||||
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
|
||||
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`}
|
||||
className={`w-full h-auto max-h-28 object-cover border border-[var(--border-primary)]/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
|
||||
/>
|
||||
</a>
|
||||
{aircraftWikiUrl && (
|
||||
@@ -393,6 +519,19 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<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="border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
|
||||
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[8px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
|
||||
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[8px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{flight.alert_link && (
|
||||
<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>
|
||||
@@ -401,6 +540,27 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{flight.alert_socials && (flight.alert_socials.twitter || flight.alert_socials.instagram) && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">SOCIALS</span>
|
||||
<div className="flex gap-2">
|
||||
{flight.alert_socials.twitter && (
|
||||
<a href={`https://x.com/${flight.alert_socials.twitter}`} target="_blank" rel="noreferrer"
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] font-mono border border-[var(--border-primary)] hover:border-cyan-500/50 hover:bg-cyan-950/30 text-cyan-400 transition-colors">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
@{flight.alert_socials.twitter}
|
||||
</a>
|
||||
)}
|
||||
{flight.alert_socials.instagram && (
|
||||
<a href={`https://instagram.com/${flight.alert_socials.instagram}`} target="_blank" rel="noreferrer"
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] font-mono border border-[var(--border-primary)] hover:border-pink-500/50 hover:bg-pink-950/30 text-pink-400 transition-colors">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="5"/><circle cx="12" cy="12" r="5"/><circle cx="17.5" cy="6.5" r="1.5"/></svg>
|
||||
@{flight.alert_socials.instagram}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{flight.icao24 && (
|
||||
<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>
|
||||
@@ -461,7 +621,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 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 flex-shrink-0"
|
||||
className="w-full bg-black/85 border border-[var(--border-primary)] flex flex-col z-10 font-mono pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<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`}>
|
||||
@@ -487,14 +647,14 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
{(aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-3">
|
||||
{aircraftImgLoading && (
|
||||
<div className="w-full h-24 rounded bg-[var(--bg-tertiary)]/60 animate-pulse" />
|
||||
<div className="w-full h-24 bg-[var(--bg-tertiary)]/60" />
|
||||
)}
|
||||
{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-[var(--border-primary)]/50 hover:border-cyan-500/50 transition-colors"
|
||||
className="w-full h-auto max-h-32 object-cover border border-[var(--border-primary)]/50 hover:border-cyan-500/50 transition-colors"
|
||||
style={{ imageRendering: 'auto' }}
|
||||
/>
|
||||
</a>
|
||||
@@ -529,6 +689,19 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<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>
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
|
||||
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[8px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
|
||||
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[8px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{flight.icao24 && (
|
||||
<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>
|
||||
@@ -581,7 +754,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 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 flex-shrink-0"
|
||||
className="w-full bg-black/85 border border-[var(--border-primary)] flex flex-col z-10 font-mono pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
|
||||
@@ -661,7 +834,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-orange-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,140,0,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/85 border border-orange-800 flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,140,0,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-orange-500/30 bg-orange-950/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-orange-400 flex items-center gap-2">
|
||||
@@ -724,7 +897,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-yellow-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,255,0,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/85 border border-yellow-800 flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,255,0,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-yellow-500/30 bg-yellow-950/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-yellow-400 flex items-center gap-2">
|
||||
@@ -768,7 +941,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-red-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,0,0,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/85 border border-red-800 flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,0,0,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-red-500/30 bg-red-950/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-red-400 flex items-center gap-2">
|
||||
@@ -786,6 +959,35 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<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.oracle_score != null && (
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">ORACLE SCORE</span>
|
||||
<span className={`text-xs font-bold ${item.oracle_score >= 7 ? 'text-red-400' : item.oracle_score >= 4 ? 'text-yellow-400' : 'text-green-400'}`}>
|
||||
{item.oracle_score}/10 [{item.oracle_score >= 7 ? 'CRITICAL' : item.oracle_score >= 4 ? 'ELEVATED' : 'ROUTINE'}]
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.sentiment != null && (
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">SENTIMENT</span>
|
||||
<span className={`text-xs font-bold ${item.sentiment <= -0.05 ? 'text-red-400' : item.sentiment >= 0.05 ? 'text-green-400' : 'text-gray-400'}`}>
|
||||
{item.sentiment > 0 ? '+' : ''}{item.sentiment.toFixed(2)} [{item.sentiment <= -0.05 ? 'NEGATIVE' : item.sentiment >= 0.05 ? 'POSITIVE' : 'NEUTRAL'}]
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.prediction_odds && item.prediction_odds.consensus_pct != null && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">MARKET CORRELATION</span>
|
||||
<div className="p-2 bg-purple-950/30 border border-purple-500/30 rounded-sm">
|
||||
<div className="text-[10px] text-purple-300 font-bold leading-tight mb-1">{item.prediction_odds.title}</div>
|
||||
<div className="flex items-center gap-3 text-[9px] font-mono">
|
||||
<span className="text-white font-bold">CONSENSUS: {item.prediction_odds.consensus_pct}%</span>
|
||||
{item.prediction_odds.polymarket_pct != null && <span className="text-cyan-400">Polymarket {item.prediction_odds.polymarket_pct}%</span>}
|
||||
{item.prediction_odds.kalshi_pct != null && <span className="text-orange-400">Kalshi {item.prediction_odds.kalshi_pct}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.machine_assessment && (
|
||||
<div className="mt-2 p-2 bg-black/60 border border-cyan-800/50 rounded-sm text-[9px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
|
||||
<div className="absolute top-0 left-0 w-[2px] h-full bg-cyan-500 animate-pulse"></div>
|
||||
@@ -815,7 +1017,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 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 flex-shrink-0"
|
||||
className="w-full bg-black/85 border border-[var(--border-primary)] flex flex-col z-10 font-mono pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||
@@ -835,7 +1037,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-green-400 text-xs font-bold">OPERATIONAL</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -843,107 +1045,15 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEntity?.type === 'cctv') {
|
||||
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 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 flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-red-400" /> {selectedEntity.extra?.last_updated
|
||||
? 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-[var(--text-muted)] font-mono">ID: {selectedEntity.id}{selectedEntity.extra?.source_agency ? ` | ${selectedEntity.extra.source_agency}` : ''}</span>
|
||||
</div>
|
||||
<div className="relative w-full h-48 bg-black flex items-center justify-center p-1">
|
||||
{(() => {
|
||||
const url = selectedEntity.media_url || '';
|
||||
const mt = selectedEntity.extra?.media_type || (
|
||||
url.includes('.mp4') || url.includes('.webm') ? 'video' :
|
||||
url.includes('.m3u8') || url.includes('hls') ? 'hls' :
|
||||
url.includes('.mjpg') || url.includes('.mjpeg') || url.includes('mjpg') ? 'mjpeg' :
|
||||
url.includes('embed') || url.includes('maps/embed') ? 'embed' :
|
||||
url.includes('mapbox.com') ? 'satellite' : 'image'
|
||||
);
|
||||
|
||||
if (mt === 'video') return (
|
||||
<video
|
||||
src={url}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
||||
/>
|
||||
);
|
||||
if (mt === 'hls') return (
|
||||
<HlsVideo
|
||||
url={url}
|
||||
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
||||
/>
|
||||
);
|
||||
if (mt === 'embed') return (
|
||||
<iframe
|
||||
src={url}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
||||
/>
|
||||
);
|
||||
if (mt === 'mjpeg') return (
|
||||
<img
|
||||
src={url}
|
||||
alt="MJPEG Feed"
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Crect fill='%23111' width='400' height='300'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%2306b6d4' font-family='monospace' font-size='14'%3EFEED UNAVAILABLE%3C/text%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
// satellite / image
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt="CCTV Feed"
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Crect fill='%23111' width='400' height='300'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%2306b6d4' font-family='monospace' font-size='14'%3ENO SIGNAL%3C/text%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Retro UI Overlay for the camera feed */}
|
||||
<div className="absolute top-2 left-2 text-[8px] text-cyan-500 bg-black/50 px-1 py-0.5 rounded">
|
||||
REC // 00:00:00:00
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-black/40 text-[9px] text-cyan-500/70 font-mono tracking-widest flex justify-between items-center">
|
||||
<span>{selectedEntity.name?.toUpperCase() || 'UNKNOWN MOUNT'}</span>
|
||||
<span className="text-red-500 text-right">
|
||||
{selectedEntity.extra?.last_updated
|
||||
? 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' })
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
/* CCTV is now handled by the fullscreen OPTIC INTERCEPT modal in MaplibreViewer */
|
||||
if (selectedEntity?.type === 'cctv') return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className={`w-full bg-[var(--bg-primary)]/40 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'}`}
|
||||
className={`w-full bg-[var(--bg-primary)]/40 backdrop-blur-sm border border-[var(--border-primary)] flex flex-col z-10 font-mono 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-[var(--border-primary)]/50 relative overflow-hidden cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
||||
@@ -964,7 +1074,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="text-[9px] text-cyan-500/80 mt-1 flex items-center justify-between font-bold relative z-10"
|
||||
className="text-[10px] text-cyan-500/80 mt-1 flex items-center justify-between font-bold relative z-10"
|
||||
>
|
||||
<span className="px-1 border border-cyan-500/30">SYS.STATUS: MONITORING</span>
|
||||
<span className="flex items-center gap-1"><Clock size={10} /> {data?.last_updated ? formatTime(data.last_updated) : "SCANNING"}</span>
|
||||
@@ -973,6 +1083,202 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Threat Level Indicator */}
|
||||
<AnimatePresence>
|
||||
{!isMinimized && data?.threat_level && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="px-3 pt-2 pb-1"
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2 py-1.5 border rounded-sm font-mono ${
|
||||
data.threat_level.level === 'SEVERE' ? 'bg-red-950/40 border-red-500/50' :
|
||||
data.threat_level.level === 'HIGH' ? 'bg-orange-950/40 border-orange-500/50' :
|
||||
data.threat_level.level === 'ELEVATED' ? 'bg-yellow-950/40 border-yellow-500/50' :
|
||||
data.threat_level.level === 'GUARDED' ? 'bg-blue-950/40 border-blue-500/50' :
|
||||
'bg-green-950/40 border-green-500/50'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
data.threat_level.level === 'SEVERE' || data.threat_level.level === 'HIGH' ? 'animate-pulse' : ''
|
||||
}`} style={{ backgroundColor: data.threat_level.color }} />
|
||||
<span className="text-[9px] font-bold tracking-wider" style={{ color: data.threat_level.color }}>
|
||||
THREAT: {data.threat_level.level}
|
||||
</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] ml-auto">
|
||||
{data.threat_level.score}/100
|
||||
</span>
|
||||
</div>
|
||||
{data.threat_level.drivers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1 mb-1">
|
||||
{data.threat_level.drivers.map((d: string, i: number) => (
|
||||
<span key={i} className="text-[7px] px-1 py-0.5 bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-[var(--text-muted)] rounded-sm">
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* DISINFORMATION INDEX — compact bar or major alert takeover */}
|
||||
<AnimatePresence>
|
||||
{!isMinimized && fimi && fimi.narratives && fimi.narratives.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="px-3 pt-1 pb-1"
|
||||
>
|
||||
{/* Compact bar */}
|
||||
<button
|
||||
onClick={() => setFimiExpanded(!fimiExpanded)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 border rounded-sm font-mono cursor-pointer transition-colors ${
|
||||
fimi.major_wave
|
||||
? 'bg-amber-950/50 border-amber-500/60 hover:bg-amber-950/70'
|
||||
: 'bg-purple-950/30 border-purple-500/30 hover:bg-purple-950/50'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
fimi.major_wave ? 'bg-amber-400 animate-pulse' : 'bg-purple-400'
|
||||
}`} />
|
||||
<span className={`text-[9px] font-bold tracking-wider ${
|
||||
fimi.major_wave ? 'text-amber-400' : 'text-purple-400'
|
||||
}`}>
|
||||
{fimi.major_wave
|
||||
? `⚠ MAJOR DISINFORMATION ALERT${fimi.major_wave_target ? ` — ${fimi.major_wave_target.toUpperCase()}` : ''}`
|
||||
: '⚠ DISINFORMATION INDEX'
|
||||
}
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)] ml-auto flex items-center gap-1">
|
||||
{Object.keys(fimi.threat_actors).length > 0 && (
|
||||
<span className="text-red-400">
|
||||
{Object.keys(fimi.threat_actors)[0]}
|
||||
</span>
|
||||
)}
|
||||
<span>{fimi.narratives.length} NARR</span>
|
||||
{fimiExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded weekly report */}
|
||||
<AnimatePresence>
|
||||
{fimiExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="mt-1 border border-purple-500/20 bg-black/40 rounded-sm overflow-hidden"
|
||||
>
|
||||
{/* Threat Actor Bar */}
|
||||
{Object.keys(fimi.threat_actors).length > 0 && (
|
||||
<div className="px-2 py-1.5 border-b border-purple-500/10">
|
||||
<div className="text-[8px] text-purple-400 tracking-widest font-bold mb-1">THREAT ACTORS</div>
|
||||
<div className="flex gap-1 h-2 rounded-sm overflow-hidden">
|
||||
{(() => {
|
||||
const total = Object.values(fimi.threat_actors).reduce((a, b) => a + b, 0);
|
||||
const actorColors: Record<string, string> = {
|
||||
'Russia': 'bg-red-500', 'China': 'bg-amber-500',
|
||||
'Iran': 'bg-purple-500', 'North Korea': 'bg-pink-500',
|
||||
'Belarus': 'bg-orange-500',
|
||||
};
|
||||
return Object.entries(fimi.threat_actors).map(([actor, count]) => (
|
||||
<div
|
||||
key={actor}
|
||||
className={`${actorColors[actor] || 'bg-gray-500'} transition-all`}
|
||||
style={{ width: `${(count / total) * 100}%` }}
|
||||
title={`${actor}: ${count} mentions`}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1 flex-wrap">
|
||||
{Object.entries(fimi.threat_actors).map(([actor, count]) => (
|
||||
<span key={actor} className="text-[7px] text-[var(--text-muted)]">
|
||||
<span className={`font-bold ${
|
||||
actor === 'Russia' ? 'text-red-400' :
|
||||
actor === 'China' ? 'text-amber-400' :
|
||||
actor === 'Iran' ? 'text-purple-400' :
|
||||
'text-gray-400'
|
||||
}`}>{actor}</span> {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Narratives */}
|
||||
<div className="px-2 py-1.5 border-b border-purple-500/10">
|
||||
<div className="text-[8px] text-purple-400 tracking-widest font-bold mb-1">LATEST NARRATIVES</div>
|
||||
<div className="flex flex-col gap-1 max-h-[120px] overflow-y-auto styled-scrollbar">
|
||||
{fimi.narratives.slice(0, 5).map((n, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={n.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[9px] text-[var(--text-secondary)] hover:text-purple-300 transition-colors leading-tight flex items-start gap-1"
|
||||
>
|
||||
<ExternalLink size={8} className="text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="flex-1">{n.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debunked Claims */}
|
||||
{fimi.claims.length > 0 && (
|
||||
<div className="px-2 py-1.5 border-b border-purple-500/10">
|
||||
<div className="text-[8px] text-red-400 tracking-widest font-bold mb-1">DEBUNKED CLAIMS ({fimi.claims.length})</div>
|
||||
<div className="flex flex-col gap-0.5 max-h-[80px] overflow-y-auto styled-scrollbar">
|
||||
{fimi.claims.slice(0, 5).map((c, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[8px] text-red-300/70 hover:text-red-300 transition-colors truncate"
|
||||
>
|
||||
✕ {c.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Countries */}
|
||||
{Object.keys(fimi.targets).length > 0 && (
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="text-[8px] text-purple-400 tracking-widest font-bold mb-1">TARGETS</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(fimi.targets).slice(0, 10).map(([target, count]) => (
|
||||
<span key={target} className="text-[7px] px-1 py-0.5 bg-purple-950/50 border border-purple-500/20 text-purple-300 rounded-sm">
|
||||
{target} ({count})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source attribution */}
|
||||
<div className="px-2 py-1 border-t border-purple-500/10 flex justify-between items-center">
|
||||
<a href={fimi.source_url} target="_blank" rel="noreferrer" className="text-[7px] text-purple-500 hover:text-purple-300 transition-colors">
|
||||
Source: {fimi.source}
|
||||
</a>
|
||||
<span className="text-[7px] text-[var(--text-muted)]">
|
||||
{fimi.last_fetched ? new Date(fimi.last_fetched).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
@@ -983,21 +1289,26 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
>
|
||||
{news.map((item: any, idx: number) => {
|
||||
let bgClass, titleClass, badgeClass;
|
||||
if (item.risk_score >= 9) {
|
||||
bgClass = "bg-red-950/20 border-red-500/30";
|
||||
const isBreaking = item.breaking === true;
|
||||
if (isBreaking) {
|
||||
bgClass = "bg-red-950/30 border-red-500/60";
|
||||
titleClass = "text-red-300 font-bold";
|
||||
badgeClass = "bg-red-500/20 text-red-300 border-red-400/50";
|
||||
} else if (item.risk_score >= 9) {
|
||||
bgClass = "bg-red-950/20 border-red-500/30";
|
||||
titleClass = "text-cyan-300 font-bold";
|
||||
badgeClass = "bg-red-500/10 text-red-400 border-red-500/30";
|
||||
} else if (item.risk_score >= 7) {
|
||||
bgClass = "bg-orange-950/20 border-orange-500/30";
|
||||
titleClass = "text-orange-300 font-bold";
|
||||
titleClass = "text-cyan-300 font-bold";
|
||||
badgeClass = "bg-orange-500/10 text-orange-400 border-orange-500/30";
|
||||
} else if (item.risk_score >= 4) {
|
||||
bgClass = "bg-yellow-950/20 border-yellow-500/30";
|
||||
titleClass = "text-yellow-300 font-bold";
|
||||
titleClass = "text-cyan-300 font-bold";
|
||||
badgeClass = "bg-yellow-500/10 text-yellow-500 border-yellow-500/30";
|
||||
} else {
|
||||
bgClass = "bg-green-950/20 border-green-500/30";
|
||||
titleClass = "text-green-300 font-medium";
|
||||
titleClass = "text-cyan-300 font-medium";
|
||||
badgeClass = "bg-green-500/10 text-green-400 border-green-500/30";
|
||||
}
|
||||
const isExpanded = expandedIndexes.includes(idx);
|
||||
@@ -1012,15 +1323,19 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
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-[var(--text-secondary)] uppercase tracking-widest">
|
||||
<span className="font-bold flex items-center gap-1 text-cyan-600">
|
||||
<span className="font-bold flex items-center gap-1 text-white">
|
||||
{isBreaking && <span className="text-red-400 mr-1">BREAKING</span>}
|
||||
>_ {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-[var(--text-primary)] transition-colors leading-tight`}>
|
||||
<button
|
||||
onClick={() => onArticleClick?.(idx, item.coords?.[0], item.coords?.[1])}
|
||||
className={`text-left text-[11px] ${titleClass} hover:text-[var(--text-primary)] transition-colors leading-tight cursor-pointer`}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</button>
|
||||
|
||||
{item.machine_assessment && (
|
||||
<div className="mt-1 p-1.5 bg-black/60 border border-cyan-800/50 rounded-sm text-[8.5px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
|
||||
@@ -1029,23 +1344,52 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<span className="text-cyan-300 opacity-90">{item.machine_assessment}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-end mt-1 relative z-10">
|
||||
<span className={`text-[8px] font-bold px-1 rounded-sm border ${badgeClass}`}>
|
||||
LVL: {item.risk_score}/10
|
||||
</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-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] 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-[var(--text-muted)] font-mono tracking-tighter">
|
||||
{item.coords[0].toFixed(2)}, {item.coords[1].toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{item.prediction_odds && item.prediction_odds.consensus_pct != null && (
|
||||
<div className="mt-1 px-1.5 py-1 bg-purple-950/30 border border-purple-500/30 rounded-sm text-[8px] font-mono flex items-center gap-1.5">
|
||||
<span className="text-purple-400 font-bold">MKT</span>
|
||||
<span className="text-purple-300 truncate flex-1" title={item.prediction_odds.title}>{item.prediction_odds.title}</span>
|
||||
<span className="text-white font-bold whitespace-nowrap">{item.prediction_odds.consensus_pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-1 relative z-10 flex-wrap">
|
||||
<span className={`text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${badgeClass}`}>
|
||||
{isBreaking ? 'BREAKING' : `LVL: ${item.risk_score}/10`}
|
||||
</span>
|
||||
{item.sentiment != null && (
|
||||
<span className={`text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
|
||||
item.sentiment < -0.1 ? 'bg-red-500/10 text-red-400 border-red-500/30' :
|
||||
item.sentiment > 0.1 ? 'bg-green-500/10 text-green-400 border-green-500/30' :
|
||||
'bg-gray-500/10 text-gray-400 border-gray-500/30'
|
||||
}`}>
|
||||
{item.sentiment < -0.1 ? '▼' : item.sentiment > 0.1 ? '▲' : '—'}{' '}
|
||||
{item.sentiment > 0 ? '+' : ''}{item.sentiment.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{item.oracle_score != null && (
|
||||
<span className={`text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
|
||||
item.oracle_score >= 7 ? 'bg-orange-500/10 text-orange-400 border-orange-500/30' :
|
||||
item.oracle_score >= 4 ? 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-cyan-500/10 text-cyan-400 border-cyan-500/30'
|
||||
}`}>
|
||||
⚡ {item.oracle_score.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{checkDisinfoLinked(item.title) && (
|
||||
<span className="text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border bg-amber-500/15 text-amber-400 border-amber-500/40 animate-pulse" title="This article echoes known disinformation narratives tracked by EUvsDisinfo">
|
||||
⚠ DISINFORMATION-LINKED
|
||||
</span>
|
||||
)}
|
||||
{item.cluster_count > 1 && (
|
||||
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold font-mono text-cyan-500 bg-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] 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-[var(--text-muted)] font-mono tracking-tighter ml-auto">
|
||||
{item.coords[0].toFixed(2)}, {item.coords[1].toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -1058,8 +1402,8 @@ 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-[var(--text-muted)] uppercase font-bold">
|
||||
<span>>_ {subItem.source}</span>
|
||||
<div className="flex items-center justify-between text-[7.5px] uppercase font-bold">
|
||||
<span className="text-white">>_ {subItem.source}</span>
|
||||
<span className={
|
||||
subItem.risk_score >= 9 ? 'text-red-400' :
|
||||
subItem.risk_score >= 7 ? 'text-orange-400' :
|
||||
@@ -1079,8 +1423,11 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
)
|
||||
})}
|
||||
{news.length === 0 && (
|
||||
<div className="text-cyan-500/50 text-[10px] tracking-widest font-bold text-center mt-6 animate-pulse">
|
||||
INITIALIZING SECURE HANDSHAKE...
|
||||
<div className="text-cyan-500/50 text-[10px] tracking-widest font-bold text-center mt-6">
|
||||
NO NEWS ITEMS LOADED
|
||||
<div className="mt-2 text-[9px] font-normal tracking-normal text-cyan-600/80">
|
||||
Feed ingest is empty or still warming up.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -1,290 +1,355 @@
|
||||
"use client";
|
||||
'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";
|
||||
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 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",
|
||||
},
|
||||
{
|
||||
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} /> },
|
||||
{ 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;
|
||||
onClose: () => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSettings }: OnboardingModalProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const OnboardingModal = React.memo(function OnboardingModal({
|
||||
onClose,
|
||||
onOpenSettings,
|
||||
}: OnboardingModalProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
onClose();
|
||||
};
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
onClose();
|
||||
onOpenSettings();
|
||||
};
|
||||
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}
|
||||
/>
|
||||
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>
|
||||
{/* 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 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 bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<Shield size={20} className="text-cyan-400" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
<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 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 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 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 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 className="bg-cyan-950/20 border border-cyan-500/20 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={14} className="text-cyan-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-[11px] text-cyan-300 font-mono font-bold mb-1">
|
||||
TRUST MODES
|
||||
</p>
|
||||
<div className="space-y-1 text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
<div>
|
||||
<span className="text-orange-300">PUBLIC / DEGRADED</span> — Meshtastic,
|
||||
APRS, and perimeter feeds. Observable and linkable.
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-yellow-300">PRIVATE / TRANSITIONAL</span> —
|
||||
Wormhole private lane is active, but strongest Reticulum posture is still warming.
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-green-300">PRIVATE / STRONG</span> — Wormhole and
|
||||
Reticulum are both ready.
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
Public mesh is not private just because Wormhole exists. Use Wormhole when
|
||||
you want the private lane, and treat public mesh as public.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
{API_GUIDES.map((api) => (
|
||||
<div
|
||||
key={api.name}
|
||||
className={`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 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 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="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 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 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 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);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const done = localStorage.getItem(STORAGE_KEY);
|
||||
if (!done) {
|
||||
setShowOnboarding(true);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const done = localStorage.getItem(STORAGE_KEY);
|
||||
if (!done) {
|
||||
setShowOnboarding(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { showOnboarding, setShowOnboarding };
|
||||
return { showOnboarding, setShowOnboarding };
|
||||
}
|
||||
|
||||
export default OnboardingModal;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,386 +1,527 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { RadioReceiver, Activity, Play, Square, FastForward, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { DashboardData, SelectedEntity, RadioFeed } from "@/types/dashboard";
|
||||
import {
|
||||
RadioReceiver,
|
||||
Activity,
|
||||
Play,
|
||||
Square,
|
||||
FastForward,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import type { DashboardData, SelectedEntity, RadioFeed, SigintSignal } from '@/types/dashboard';
|
||||
|
||||
export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesdropping, eavesdropLocation, cameraCenter, selectedEntity }: { data: DashboardData, isEavesdropping?: boolean, setIsEavesdropping?: (val: boolean) => void, eavesdropLocation?: { lat: number, lng: number } | null, cameraCenter?: { lat: number, lng: number } | null, selectedEntity?: SelectedEntity | null }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [feeds, setFeeds] = useState<RadioFeed[]>([]);
|
||||
const [activeFeed, setActiveFeed] = useState<RadioFeed | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const scanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
export default function RadioInterceptPanel({
|
||||
data,
|
||||
isEavesdropping,
|
||||
setIsEavesdropping,
|
||||
eavesdropLocation,
|
||||
cameraCenter,
|
||||
selectedEntity: _selectedEntity,
|
||||
}: {
|
||||
data: DashboardData;
|
||||
isEavesdropping?: boolean;
|
||||
setIsEavesdropping?: (val: boolean) => void;
|
||||
eavesdropLocation?: { lat: number; lng: number } | null;
|
||||
cameraCenter?: { lat: number; lng: number } | null;
|
||||
selectedEntity?: SelectedEntity | null;
|
||||
}) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [feeds, setFeeds] = useState<RadioFeed[]>([]);
|
||||
const [activeFeed, setActiveFeed] = useState<RadioFeed | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const volumeRef = useRef(volume);
|
||||
const scanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Fetch the top feeds on mount
|
||||
useEffect(() => {
|
||||
const fetchFeeds = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/radio/top`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setFeeds(json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch radio feeds", e);
|
||||
}
|
||||
};
|
||||
fetchFeeds();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchFeeds, 300000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
// Fetch the top feeds on mount
|
||||
useEffect(() => {
|
||||
const fetchFeeds = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/radio/top`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setFeeds(json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch radio feeds', e);
|
||||
}
|
||||
};
|
||||
fetchFeeds();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchFeeds, 300000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Handle Eavesdrop Map Clicks
|
||||
useEffect(() => {
|
||||
if (eavesdropLocation && isEavesdropping) {
|
||||
const fetchNearest = async () => {
|
||||
try {
|
||||
// Show a temporary state
|
||||
setFeeds(prev => [{
|
||||
id: 'scanning-nearest',
|
||||
name: 'TRIANGULATING SIGNAL...',
|
||||
location: `LAT:${eavesdropLocation.lat.toFixed(2)} LNG:${eavesdropLocation.lng.toFixed(2)}`,
|
||||
const playFeed = useCallback((feed: RadioFeed) => {
|
||||
if (isScanning && scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
setIsScanning(false);
|
||||
}
|
||||
setActiveFeed(feed);
|
||||
setIsPlaying(true);
|
||||
}, [isScanning]);
|
||||
|
||||
// Handle Eavesdrop Map Clicks
|
||||
useEffect(() => {
|
||||
if (eavesdropLocation && isEavesdropping) {
|
||||
const fetchNearest = async () => {
|
||||
try {
|
||||
// Show a temporary state
|
||||
setFeeds((prev) => [
|
||||
{
|
||||
id: 'scanning-nearest',
|
||||
name: 'TRIANGULATING SIGNAL...',
|
||||
location: `LAT:${eavesdropLocation.lat.toFixed(2)} LNG:${eavesdropLocation.lng.toFixed(2)}`,
|
||||
listeners: 0,
|
||||
category: 'SIGINT',
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
|
||||
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(
|
||||
`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`,
|
||||
);
|
||||
if (callRes.ok) {
|
||||
const calls = await callRes.json();
|
||||
if (calls && calls.length > 0) {
|
||||
// Found bursts!
|
||||
const latest = calls[0];
|
||||
const openMhzFeed = {
|
||||
id: `openmhz-${system.shortName}-${latest.id}`,
|
||||
name: `${system.name} (TG:${latest.talkgroupNum})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
listeners: system.clientCount || 0,
|
||||
category: 'TRUNKED INTERCEPT',
|
||||
stream_url: latest.url,
|
||||
};
|
||||
|
||||
// Remove the triangulating placeholder and add the new intercept
|
||||
setFeeds((prev) => {
|
||||
const clean = prev.filter((f) => f.id !== 'scanning-nearest');
|
||||
// Avoid duplicates if we clicked the same place twice
|
||||
if (clean.find((f) => f.id === openMhzFeed.id)) return clean;
|
||||
return [openMhzFeed, ...clean];
|
||||
});
|
||||
// Auto-play the intercept
|
||||
playFeed(openMhzFeed);
|
||||
} else {
|
||||
// Provide failure feedback
|
||||
setFeeds((prev) => {
|
||||
const clean = prev.filter((f) => f.id !== 'scanning-nearest');
|
||||
return [
|
||||
{
|
||||
id: `failed-${Date.now()}`,
|
||||
name: `NO RECENT COMMS (${system.shortName})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
category: 'DEAD AIR',
|
||||
listeners: 0,
|
||||
category: 'SIGINT'
|
||||
}, ...prev]);
|
||||
|
||||
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(`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`);
|
||||
if (callRes.ok) {
|
||||
const calls = await callRes.json();
|
||||
if (calls && calls.length > 0) {
|
||||
// Found bursts!
|
||||
const latest = calls[0];
|
||||
const openMhzFeed = {
|
||||
id: `openmhz-${system.shortName}-${latest.id}`,
|
||||
name: `${system.name} (TG:${latest.talkgroupNum})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
listeners: system.clientCount || 0,
|
||||
category: 'TRUNKED INTERCEPT',
|
||||
stream_url: latest.url
|
||||
};
|
||||
|
||||
// Remove the triangulating placeholder and add the new intercept
|
||||
setFeeds(prev => {
|
||||
const clean = prev.filter(f => f.id !== 'scanning-nearest');
|
||||
// Avoid duplicates if we clicked the same place twice
|
||||
if (clean.find(f => f.id === openMhzFeed.id)) return clean;
|
||||
return [openMhzFeed, ...clean];
|
||||
});
|
||||
// Auto-play the intercept
|
||||
playFeed(openMhzFeed);
|
||||
} else {
|
||||
// Provide failure feedback
|
||||
setFeeds(prev => {
|
||||
const clean = prev.filter(f => f.id !== 'scanning-nearest');
|
||||
return [{
|
||||
id: `failed-${Date.now()}`,
|
||||
name: `NO RECENT COMMS (${system.shortName})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
category: 'DEAD AIR',
|
||||
listeners: 0
|
||||
}, ...clean];
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Provide failure feedback
|
||||
setFeeds(prev => {
|
||||
const clean = prev.filter(f => f.id !== 'scanning-nearest');
|
||||
return [{
|
||||
id: `failed-${Date.now()}`,
|
||||
name: 'NO LOCAL REPEATERS FOUND',
|
||||
location: 'UNKNOWN',
|
||||
category: 'ENCRYPTED / VOID',
|
||||
listeners: 0
|
||||
}, ...clean];
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Nearest system lookup failed", e);
|
||||
},
|
||||
...clean,
|
||||
];
|
||||
});
|
||||
}
|
||||
};
|
||||
fetchNearest();
|
||||
}
|
||||
}, [eavesdropLocation]);
|
||||
|
||||
const playFeed = (feed: RadioFeed) => {
|
||||
if (isScanning && scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
setIsScanning(false);
|
||||
}
|
||||
setActiveFeed(feed);
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const stopFeed = () => {
|
||||
if (isScanning && scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
setIsScanning(false);
|
||||
}
|
||||
setActiveFeed(null);
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
// Handle Audio Element Play/Stop
|
||||
useEffect(() => {
|
||||
if (activeFeed && isPlaying) {
|
||||
if (!audioRef.current) {
|
||||
const audio = new Audio(activeFeed.stream_url || '');
|
||||
audioRef.current = audio;
|
||||
}
|
||||
} else {
|
||||
audioRef.current.src = activeFeed.stream_url || '';
|
||||
}
|
||||
audioRef.current.volume = volume;
|
||||
audioRef.current.play().catch(e => console.log("Audio play blocked", e));
|
||||
} else {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = "";
|
||||
// Provide failure feedback
|
||||
setFeeds((prev) => {
|
||||
const clean = prev.filter((f) => f.id !== 'scanning-nearest');
|
||||
return [
|
||||
{
|
||||
id: `failed-${Date.now()}`,
|
||||
name: 'NO LOCAL REPEATERS FOUND',
|
||||
location: 'UNKNOWN',
|
||||
category: 'ENCRYPTED / VOID',
|
||||
listeners: 0,
|
||||
},
|
||||
...clean,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Nearest system lookup failed', e);
|
||||
}
|
||||
}, [activeFeed, isPlaying]);
|
||||
};
|
||||
fetchNearest();
|
||||
}
|
||||
}, [eavesdropLocation, isEavesdropping, playFeed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume;
|
||||
}
|
||||
}, [volume]);
|
||||
const stopFeed = useCallback(() => {
|
||||
if (isScanning && scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
setIsScanning(false);
|
||||
}
|
||||
setActiveFeed(null);
|
||||
setIsPlaying(false);
|
||||
}, [isScanning]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = "";
|
||||
}
|
||||
if (scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// Handle Audio Element Play/Stop
|
||||
useEffect(() => {
|
||||
if (activeFeed && isPlaying) {
|
||||
if (!audioRef.current) {
|
||||
const audio = new Audio(activeFeed.stream_url || '');
|
||||
audioRef.current = audio;
|
||||
} else {
|
||||
audioRef.current.src = activeFeed.stream_url || '';
|
||||
}
|
||||
audioRef.current.volume = volumeRef.current;
|
||||
audioRef.current.play().catch((e) => console.log('Audio play blocked', e));
|
||||
} else {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
}
|
||||
}, [activeFeed, isPlaying]);
|
||||
|
||||
const toggleScan = () => {
|
||||
if (isScanning) {
|
||||
setIsScanning(false);
|
||||
if (scanTimeoutRef.current) clearTimeout(scanTimeoutRef.current);
|
||||
stopFeed();
|
||||
} else {
|
||||
setIsScanning(true);
|
||||
scanNextFeed();
|
||||
}
|
||||
useEffect(() => {
|
||||
volumeRef.current = volume;
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
if (scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scanNextFeed = async () => {
|
||||
if (!isScanning) return;
|
||||
const toggleScan = () => {
|
||||
if (isScanning) {
|
||||
setIsScanning(false);
|
||||
if (scanTimeoutRef.current) clearTimeout(scanTimeoutRef.current);
|
||||
stopFeed();
|
||||
} else {
|
||||
setIsScanning(true);
|
||||
scanNextFeed();
|
||||
}
|
||||
};
|
||||
|
||||
// Try localized scan first if we have a camera center or eavesdrop location
|
||||
const scanLoc = eavesdropLocation || cameraCenter;
|
||||
const scanNextFeed = async () => {
|
||||
if (!isScanning) return;
|
||||
|
||||
let localFeedFound = false;
|
||||
// Try localized scan first if we have a camera center or eavesdrop location
|
||||
const scanLoc = eavesdropLocation || cameraCenter;
|
||||
|
||||
if (scanLoc) {
|
||||
try {
|
||||
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();
|
||||
let localFeedFound = false;
|
||||
|
||||
// Try to find a system with an active unplayed burst
|
||||
for (const system of systems) {
|
||||
if (system && 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) {
|
||||
// Normally we would track played calls. For now just pick random recent one.
|
||||
const randomCall = calls[Math.floor(Math.random() * Math.min(calls.length, 3))];
|
||||
const openMhzFeed = {
|
||||
id: `openmhz-${system.shortName}-${randomCall.id}`,
|
||||
name: `${system.name} (TG:${randomCall.talkgroupNum})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
listeners: system.clientCount || 0,
|
||||
category: 'TRUNKED INTERCEPT',
|
||||
stream_url: randomCall.url
|
||||
};
|
||||
if (scanLoc) {
|
||||
try {
|
||||
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();
|
||||
|
||||
// Replace feeds list visually with this active sector
|
||||
setFeeds(prev => {
|
||||
if (prev.find(f => f.id === openMhzFeed.id)) return prev;
|
||||
return [openMhzFeed, ...prev].slice(0, 10);
|
||||
});
|
||||
setActiveFeed(openMhzFeed);
|
||||
setIsPlaying(true);
|
||||
localFeedFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try to find a system with an active unplayed burst
|
||||
for (const system of systems) {
|
||||
if (system && 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) {
|
||||
// Normally we would track played calls. For now just pick random recent one.
|
||||
const randomCall = calls[Math.floor(Math.random() * Math.min(calls.length, 3))];
|
||||
const openMhzFeed = {
|
||||
id: `openmhz-${system.shortName}-${randomCall.id}`,
|
||||
name: `${system.name} (TG:${randomCall.talkgroupNum})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
listeners: system.clientCount || 0,
|
||||
category: 'TRUNKED INTERCEPT',
|
||||
stream_url: randomCall.url,
|
||||
};
|
||||
|
||||
// Replace feeds list visually with this active sector
|
||||
setFeeds((prev) => {
|
||||
if (prev.find((f) => f.id === openMhzFeed.id)) return prev;
|
||||
return [openMhzFeed, ...prev].slice(0, 10);
|
||||
});
|
||||
setActiveFeed(openMhzFeed);
|
||||
setIsPlaying(true);
|
||||
localFeedFound = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Auto scan local query failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Auto scan local query failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localFeedFound && feeds.length > 0) {
|
||||
// Fallback: Pick a random hot feed or cycle them
|
||||
const randomIdx = Math.floor(Math.random() * Math.min(feeds.length, 10)); // Pick from top 10
|
||||
setActiveFeed(feeds[randomIdx]);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
if (!localFeedFound && feeds.length > 0) {
|
||||
// Fallback: Pick a random hot feed or cycle them
|
||||
const randomIdx = Math.floor(Math.random() * Math.min(feeds.length, 10)); // Pick from top 10
|
||||
setActiveFeed(feeds[randomIdx]);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
|
||||
// Scan for 15 seconds then switch
|
||||
scanTimeoutRef.current = setTimeout(() => {
|
||||
if (isScanning) scanNextFeed();
|
||||
}, 15000);
|
||||
};
|
||||
// Scan for 15 seconds then switch
|
||||
scanTimeoutRef.current = setTimeout(() => {
|
||||
if (isScanning) scanNextFeed();
|
||||
}, 15000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="w-full flex flex-col 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)] relative overflow-hidden max-h-full"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 border-b border-[var(--border-primary)]/50 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[var(--text-muted)]">
|
||||
<RadioReceiver size={14} className={isPlaying ? "animate-pulse" : ""} />
|
||||
<span className="text-[10px] font-mono tracking-widest">SIGINT INTERCEPT</span>
|
||||
{isPlaying && <Activity size={12} className="text-red-500 animate-pulse ml-2" />}
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="w-full flex flex-col bg-[var(--bg-primary)]/40 backdrop-blur-sm border border-[var(--border-primary)] pointer-events-auto relative overflow-hidden max-h-full"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]/50 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[var(--text-muted)]">
|
||||
<RadioReceiver size={14} className={isPlaying ? 'animate-pulse' : ''} />
|
||||
<span className="text-[10px] font-mono tracking-widest">SIGINT INTERCEPT</span>
|
||||
{isPlaying && <Activity size={12} className="text-red-500 animate-pulse ml-2" />}
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Audio Player Controls */}
|
||||
<div className="p-4 border-b border-[var(--border-primary)]/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-[var(--text-muted)] font-mono">
|
||||
{activeFeed
|
||||
? `LOCATION: ${activeFeed.location.toUpperCase()}`
|
||||
: 'AWAITING TUNING...'}
|
||||
</span>
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
{activeFeed && (
|
||||
<div className="flex items-center gap-1 bg-red-950/40 border border-red-900/50 px-2 py-0.5 text-[9px] text-red-400 font-mono">
|
||||
<Activity size={10} className="animate-pulse" />
|
||||
LIVE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={activeFeed ? stopFeed : () => feeds.length > 0 && playFeed(feeds[0])}
|
||||
className={`p-2 rounded-full border ${activeFeed ? 'border-red-500/50 text-red-500 hover:bg-red-950/50' : 'border-cyan-700 text-cyan-500 hover:bg-cyan-900/50'} transition-colors`}
|
||||
>
|
||||
{activeFeed ? <Square size={14} /> : <Play size={14} className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleScan}
|
||||
className={`px-3 py-1.5 text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isScanning ? 'bg-cyan-900/60 border-cyan-400 text-cyan-300' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
||||
>
|
||||
<FastForward size={12} />
|
||||
{isScanning ? 'SCANNING...' : 'AUTO SCAN'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsEavesdropping && setIsEavesdropping(!isEavesdropping)}
|
||||
className={`px-3 py-1.5 text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isEavesdropping ? 'bg-red-900/60 border-red-500 text-red-300 animate-pulse' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
||||
title="Click on the globe to intercept local signals"
|
||||
>
|
||||
EAVESDROP
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-20 accent-cyan-500"
|
||||
title="Volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fake Waveform Visualizer */}
|
||||
<div className="mt-4 flex items-end gap-[2px] h-8 opacity-70">
|
||||
{Array.from({ length: 48 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={`w-1 rounded-t-sm ${isPlaying ? 'bg-cyan-500' : 'bg-cyan-900/50'}`}
|
||||
animate={{
|
||||
height: isPlaying ? ['10%', `${Math.random() * 80 + 20}%`, '10%'] : '10%',
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: Math.random() * 0.5 + 0.3,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Audio Player Controls */}
|
||||
<div className="p-4 border-b border-[var(--border-primary)]/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-[var(--text-muted)] font-mono">
|
||||
{activeFeed ? `LOCATION: ${activeFeed.location.toUpperCase()}` : "AWAITING TUNING..."}
|
||||
</span>
|
||||
</div>
|
||||
{activeFeed && (
|
||||
<div className="flex items-center gap-1 bg-red-950/40 border border-red-900/50 px-2 py-0.5 rounded text-[9px] text-red-400 font-mono">
|
||||
<Activity size={10} className="animate-pulse" />
|
||||
LIVE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Feed List */}
|
||||
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
||||
{feeds.length === 0 ? (
|
||||
<div className="text-[10px] text-cyan-700 font-mono text-center p-4">
|
||||
SEARCHING FREQUENCIES...
|
||||
</div>
|
||||
) : (
|
||||
feeds.map((feed: RadioFeed) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
onClick={() => playFeed(feed)}
|
||||
className={`p-2 mb-1 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-[var(--text-secondary)]'}`}
|
||||
>
|
||||
{feed.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono truncate">
|
||||
{feed.location} | {feed.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end flex-shrink-0">
|
||||
<span className="text-[10px] text-cyan-600 font-mono flex items-center gap-1">
|
||||
<Activity size={10} />
|
||||
{feed.listeners.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">
|
||||
LSTN
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={activeFeed ? stopFeed : () => feeds.length > 0 && playFeed(feeds[0])}
|
||||
className={`p-2 rounded-full border ${activeFeed ? 'border-red-500/50 text-red-500 hover:bg-red-950/50' : 'border-cyan-700 text-cyan-500 hover:bg-cyan-900/50'} transition-colors`}
|
||||
>
|
||||
{activeFeed ? <Square size={14} /> : <Play size={14} className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleScan}
|
||||
className={`px-3 py-1.5 rounded text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isScanning ? 'bg-cyan-900/60 border-cyan-400 text-cyan-300' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
||||
>
|
||||
<FastForward size={12} />
|
||||
{isScanning ? 'SCANNING...' : 'AUTO SCAN'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsEavesdropping && setIsEavesdropping(!isEavesdropping)}
|
||||
className={`px-3 py-1.5 rounded text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isEavesdropping ? 'bg-red-900/60 border-red-500 text-red-300 animate-pulse' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
||||
title="Click on the globe to intercept local signals"
|
||||
>
|
||||
EAVESDROP
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0" max="1" step="0.05"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-20 accent-cyan-500"
|
||||
title="Volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fake Waveform Visualizer */}
|
||||
<div className="mt-4 flex items-end gap-[2px] h-8 opacity-70">
|
||||
{Array.from({ length: 48 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={`w-1 rounded-t-sm ${isPlaying ? 'bg-cyan-500' : 'bg-cyan-900/50'}`}
|
||||
animate={{
|
||||
height: isPlaying
|
||||
? ['10%', `${Math.random() * 80 + 20}%`, '10%']
|
||||
: '10%'
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: Math.random() * 0.5 + 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed List */}
|
||||
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
||||
{feeds.length === 0 ? (
|
||||
<div className="text-[10px] text-cyan-700 font-mono text-center p-4">SEARCHING FREQUENCIES...</div>
|
||||
) : (
|
||||
feeds.map((feed: RadioFeed, idx: number) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
onClick={() => playFeed(feed)}
|
||||
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-[var(--text-secondary)]'}`}>
|
||||
{feed.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono truncate">
|
||||
{feed.location} | {feed.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end flex-shrink-0">
|
||||
<span className="text-[10px] text-cyan-600 font-mono flex items-center gap-1">
|
||||
<Activity size={10} />
|
||||
{feed.listeners.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">LSTN</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
{/* SIGINT Grid Section */}
|
||||
{data?.sigint && data.sigint.length > 0 && (
|
||||
<div className="border-t border-[var(--border-primary)]/40">
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<span className="text-[9px] font-mono tracking-widest text-emerald-400 font-bold">
|
||||
SIGINT GRID
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[8px] font-mono">
|
||||
<span className="text-green-400">
|
||||
APRS:{data.sigint.filter((s: SigintSignal) => s.source === 'aprs').length}
|
||||
</span>
|
||||
<span className="text-blue-400">
|
||||
MESH:
|
||||
{data.sigint.filter((s: SigintSignal) => s.source === 'meshtastic').length}
|
||||
</span>
|
||||
<span className="text-amber-400">
|
||||
JS8:{data.sigint.filter((s: SigintSignal) => s.source === 'js8call').length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-col overflow-y-auto styled-scrollbar max-h-60 px-2 pb-2">
|
||||
{data.sigint.slice(0, 25).map((sig: SigintSignal, idx: number) => {
|
||||
const srcColor =
|
||||
sig.source === 'aprs'
|
||||
? '#22c55e'
|
||||
: sig.source === 'meshtastic'
|
||||
? '#3b82f6'
|
||||
: '#f59e0b';
|
||||
// Build a context line from the richest available field
|
||||
const context =
|
||||
sig.status || sig.comment || sig.raw_message?.slice(0, 60) || '';
|
||||
const stationType =
|
||||
sig.station_type && sig.station_type !== 'Station' ? sig.station_type : '';
|
||||
const freq = sig.frequency || '';
|
||||
return (
|
||||
<div
|
||||
key={`${sig.source}-${sig.callsign}-${idx}`}
|
||||
className={`p-1.5 mb-0.5 hover:bg-white/5 transition-colors border-l-2 cursor-pointer ${sig.emergency ? 'bg-red-950/20' : ''}`}
|
||||
style={{ borderColor: sig.emergency ? '#ef4444' : srcColor }}
|
||||
onClick={() => {
|
||||
if (sig.lat && sig.lng) {
|
||||
// Dispatch a custom event to fly to this signal
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('flyto', {
|
||||
detail: { lat: sig.lat, lng: sig.lng, zoom: 10 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] font-mono text-[var(--text-secondary)] truncate font-medium">
|
||||
{sig.callsign}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{sig.emergency && (
|
||||
<span className="text-[7px] font-mono text-red-400 bg-red-500/20 px-1 tracking-wider">
|
||||
SOS
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[7px] font-mono tracking-wider px-1"
|
||||
style={{ color: srcColor, backgroundColor: `${srcColor}15` }}
|
||||
>
|
||||
{(sig.source || '').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
{(stationType || freq) && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{stationType && (
|
||||
<span className="text-[8px] text-cyan-500/70 font-mono truncate">
|
||||
{stationType}
|
||||
</span>
|
||||
)}
|
||||
{freq && (
|
||||
<span className="text-[8px] text-amber-500/70 font-mono">{freq}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{context && (
|
||||
<p className="text-[8px] text-gray-400 font-mono truncate mt-0.5 leading-tight">
|
||||
{context.slice(0, 70)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { Ruler, X, Trash2 } from "lucide-react";
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Ruler, Trash2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Dynamic Scale Bar with:
|
||||
@@ -10,188 +10,206 @@ import { Ruler, X, Trash2 } from "lucide-react";
|
||||
* 3. Measurement mode toggle — lets the user place up to 3 waypoints on the map
|
||||
*/
|
||||
|
||||
const NICE_METRIC = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000];
|
||||
const NICE_IMPERIAL = [0.1, 0.25, 0.5, 1, 2, 5, 10, 25, 50, 100, 200, 500, 1000, 2000, 5000];
|
||||
|
||||
const MILES_PER_METER = 0.000621371;
|
||||
const KM_PER_METER = 0.001;
|
||||
|
||||
/** Metres per pixel at a given zoom & latitude (Web Mercator). */
|
||||
function metersPerPixel(zoom: number, latitude: number) {
|
||||
return (156543.03392 * Math.cos((latitude * Math.PI) / 180)) / Math.pow(2, zoom);
|
||||
return (156543.03392 * Math.cos((latitude * Math.PI) / 180)) / Math.pow(2, zoom);
|
||||
}
|
||||
|
||||
/** Format a metric distance nicely. */
|
||||
function fmtMetric(km: number) {
|
||||
return km >= 1 ? `${km.toFixed(km < 10 ? 1 : 0)} km` : `${Math.round(km * 1000)} m`;
|
||||
return km >= 1 ? `${km.toFixed(km < 10 ? 1 : 0)} km` : `${Math.round(km * 1000)} m`;
|
||||
}
|
||||
/** Format an imperial distance nicely. */
|
||||
function fmtImperial(mi: number) {
|
||||
return mi >= 1 ? `${mi.toFixed(mi < 10 ? 1 : 0)} mi` : `${Math.round(mi * 5280)} ft`;
|
||||
return mi >= 1 ? `${mi.toFixed(mi < 10 ? 1 : 0)} mi` : `${Math.round(mi * 5280)} ft`;
|
||||
}
|
||||
|
||||
interface MeasurePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface ScaleBarProps {
|
||||
zoom: number;
|
||||
latitude: number;
|
||||
measureMode?: boolean;
|
||||
measurePoints?: MeasurePoint[];
|
||||
onToggleMeasure?: () => void;
|
||||
onClearMeasure?: () => void;
|
||||
zoom: number;
|
||||
latitude: number;
|
||||
measureMode?: boolean;
|
||||
measurePoints?: MeasurePoint[];
|
||||
onToggleMeasure?: () => void;
|
||||
onClearMeasure?: () => void;
|
||||
}
|
||||
|
||||
function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure, onClearMeasure }: ScaleBarProps) {
|
||||
const [unit, setUnit] = useState<"mi" | "km">("mi");
|
||||
const [barWidth, setBarWidth] = useState(120); // current bar width in px
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startW = useRef(0);
|
||||
function ScaleBar({
|
||||
zoom,
|
||||
latitude,
|
||||
measureMode,
|
||||
measurePoints,
|
||||
onToggleMeasure,
|
||||
onClearMeasure,
|
||||
}: ScaleBarProps) {
|
||||
const [unit, setUnit] = useState<'mi' | 'km'>('mi');
|
||||
const [barWidth, setBarWidth] = useState(120); // current bar width in px
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startW = useRef(0);
|
||||
|
||||
const MIN_BAR = 60;
|
||||
const MAX_BAR = 280;
|
||||
const MIN_BAR = 60;
|
||||
const MAX_BAR = 280;
|
||||
|
||||
// ── Draggable right edge ──
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
startW.current = barWidth;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [barWidth]);
|
||||
// ── Draggable right edge ──
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
startW.current = barWidth;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[barWidth],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging.current) return;
|
||||
const dx = e.clientX - startX.current;
|
||||
setBarWidth(Math.max(MIN_BAR, Math.min(MAX_BAR, startW.current + dx)));
|
||||
}, []);
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging.current) return;
|
||||
const dx = e.clientX - startX.current;
|
||||
setBarWidth(Math.max(MIN_BAR, Math.min(MAX_BAR, startW.current + dx)));
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragging.current = false;
|
||||
}, []);
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragging.current = false;
|
||||
}, []);
|
||||
|
||||
// ── Distance label for the current bar width ──
|
||||
const scaleLabel = useMemo(() => {
|
||||
const mpp = metersPerPixel(zoom, latitude);
|
||||
const totalMeters = mpp * barWidth;
|
||||
if (unit === "km") {
|
||||
return fmtMetric(totalMeters * KM_PER_METER);
|
||||
} else {
|
||||
return fmtImperial(totalMeters * MILES_PER_METER);
|
||||
}
|
||||
}, [zoom, latitude, barWidth, unit]);
|
||||
// ── Distance label for the current bar width ──
|
||||
const scaleLabel = useMemo(() => {
|
||||
const mpp = metersPerPixel(zoom, latitude);
|
||||
const totalMeters = mpp * barWidth;
|
||||
if (unit === 'km') {
|
||||
return fmtMetric(totalMeters * KM_PER_METER);
|
||||
} else {
|
||||
return fmtImperial(totalMeters * MILES_PER_METER);
|
||||
}
|
||||
}, [zoom, latitude, barWidth, unit]);
|
||||
|
||||
// ── Measurement distances ──
|
||||
const segmentDistances = useMemo(() => {
|
||||
if (!measurePoints || measurePoints.length < 2) return [];
|
||||
const dists: string[] = [];
|
||||
let total = 0;
|
||||
for (let i = 1; i < measurePoints.length; i++) {
|
||||
const d = haversine(measurePoints[i - 1], measurePoints[i]);
|
||||
total += d;
|
||||
if (unit === "km") dists.push(fmtMetric(d / 1000));
|
||||
else dists.push(fmtImperial(d * MILES_PER_METER));
|
||||
}
|
||||
if (measurePoints.length > 2) {
|
||||
if (unit === "km") dists.push(`Σ ${fmtMetric(total / 1000)}`);
|
||||
else dists.push(`Σ ${fmtImperial(total * MILES_PER_METER)}`);
|
||||
}
|
||||
return dists;
|
||||
}, [measurePoints, unit]);
|
||||
// ── Measurement distances ──
|
||||
const segmentDistances = useMemo(() => {
|
||||
if (!measurePoints || measurePoints.length < 2) return [];
|
||||
const dists: string[] = [];
|
||||
let total = 0;
|
||||
for (let i = 1; i < measurePoints.length; i++) {
|
||||
const d = haversine(measurePoints[i - 1], measurePoints[i]);
|
||||
total += d;
|
||||
if (unit === 'km') dists.push(fmtMetric(d / 1000));
|
||||
else dists.push(fmtImperial(d * MILES_PER_METER));
|
||||
}
|
||||
if (measurePoints.length > 2) {
|
||||
if (unit === 'km') dists.push(`Σ ${fmtMetric(total / 1000)}`);
|
||||
else dists.push(`Σ ${fmtImperial(total * MILES_PER_METER)}`);
|
||||
}
|
||||
return dists;
|
||||
}, [measurePoints, unit]);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-3 select-none">
|
||||
{/* Scale ruler */}
|
||||
<div className="flex flex-col items-start">
|
||||
<div
|
||||
className="flex items-end relative"
|
||||
style={{ width: barWidth }}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{/* Left tick */}
|
||||
<div className="w-px h-2.5 bg-cyan-400 flex-shrink-0" />
|
||||
{/* Bar */}
|
||||
<div className="flex-1 h-px bg-cyan-400 relative" style={{ boxShadow: "0 0 6px rgba(0,255,255,0.3)" }}>
|
||||
{/* Graduation marks */}
|
||||
<div className="absolute left-1/4 top-0 w-px h-1.5 bg-cyan-400/50" />
|
||||
<div className="absolute left-1/2 top-0 w-px h-2 bg-cyan-400/70" />
|
||||
<div className="absolute left-3/4 top-0 w-px h-1.5 bg-cyan-400/50" />
|
||||
</div>
|
||||
{/* Draggable right tick */}
|
||||
<div
|
||||
className="w-2 h-3 bg-cyan-400/80 rounded-r cursor-ew-resize flex-shrink-0 hover:bg-cyan-300 transition-colors"
|
||||
onPointerDown={onPointerDown}
|
||||
title="Drag to resize scale"
|
||||
style={{ touchAction: "none" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-cyan-300 tracking-widest mt-0.5">{scaleLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* 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-[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"}
|
||||
</button>
|
||||
|
||||
{/* Measure mode toggle */}
|
||||
<button
|
||||
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-[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)"}
|
||||
>
|
||||
<Ruler size={10} />
|
||||
{measureMode ? "MEASURING" : "MEASURE"}
|
||||
</button>
|
||||
|
||||
{/* Clear measurements */}
|
||||
{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-[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} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Segment distances readout */}
|
||||
{segmentDistances.length > 0 && (
|
||||
<div className="flex items-center gap-2 ml-1">
|
||||
{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-[var(--border-primary)] text-[var(--text-secondary)]"
|
||||
}`}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className="flex items-end gap-3 select-none">
|
||||
{/* Scale ruler */}
|
||||
<div className="flex flex-col items-start">
|
||||
<div
|
||||
className="flex items-end relative"
|
||||
style={{ width: barWidth }}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{/* Left tick */}
|
||||
<div className="w-px h-2.5 bg-cyan-400 flex-shrink-0" />
|
||||
{/* Bar */}
|
||||
<div
|
||||
className="flex-1 h-px bg-cyan-400 relative"
|
||||
style={{ boxShadow: '0 0 6px rgba(0,255,255,0.3)' }}
|
||||
>
|
||||
{/* Graduation marks */}
|
||||
<div className="absolute left-1/4 top-0 w-px h-1.5 bg-cyan-400/50" />
|
||||
<div className="absolute left-1/2 top-0 w-px h-2 bg-cyan-400/70" />
|
||||
<div className="absolute left-3/4 top-0 w-px h-1.5 bg-cyan-400/50" />
|
||||
</div>
|
||||
{/* Draggable right tick */}
|
||||
<div
|
||||
className="w-2 h-3 bg-cyan-400/80 rounded-r cursor-ew-resize flex-shrink-0 hover:bg-cyan-300 transition-colors"
|
||||
onPointerDown={onPointerDown}
|
||||
title="Drag to resize scale"
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<span className="text-[9px] font-mono text-cyan-300 tracking-widest mt-0.5">
|
||||
{scaleLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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-[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'}
|
||||
</button>
|
||||
|
||||
{/* Measure mode toggle */}
|
||||
<button
|
||||
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-[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)'}
|
||||
>
|
||||
<Ruler size={10} />
|
||||
{measureMode ? 'MEASURING' : 'MEASURE'}
|
||||
</button>
|
||||
|
||||
{/* Clear measurements */}
|
||||
{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-[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} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Segment distances readout */}
|
||||
{segmentDistances.length > 0 && (
|
||||
<div className="flex items-center gap-2 ml-1">
|
||||
{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-[var(--border-primary)] text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Haversine distance in meters between two lat/lng points. */
|
||||
function haversine(a: MeasurePoint, b: MeasurePoint): number {
|
||||
const R = 6371000;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const sa = Math.sin(dLat / 2);
|
||||
const sb = Math.sin(dLng / 2);
|
||||
const h = sa * sa + Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * sb * sb;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
const R = 6371000;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const sa = Math.sin(dLat / 2);
|
||||
const sb = Math.sin(dLng / 2);
|
||||
const h =
|
||||
sa * sa + Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * sb * sb;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
export default React.memo(ScaleBar);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,941 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
KeyRound,
|
||||
Radar,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import type { SelectedEntity } from '@/types/dashboard';
|
||||
import type {
|
||||
ShodanCountResponse,
|
||||
ShodanHost,
|
||||
ShodanSearchMatch,
|
||||
ShodanStatusResponse,
|
||||
ShodanStyleConfig,
|
||||
ShodanMarkerShape,
|
||||
ShodanMarkerSize,
|
||||
} from '@/types/shodan';
|
||||
import { countShodan, fetchShodanStatus, lookupShodanHost, searchShodan } from '@/lib/shodanClient';
|
||||
|
||||
type Mode = 'search' | 'count' | 'host';
|
||||
type ShodanPreset = {
|
||||
id: string;
|
||||
label: string;
|
||||
mode: Mode;
|
||||
query: string;
|
||||
page: number;
|
||||
facets: string;
|
||||
hostIp: string;
|
||||
style?: ShodanStyleConfig;
|
||||
};
|
||||
|
||||
const SHODAN_PRESETS_KEY = 'sb_shodan_presets_v1';
|
||||
const SHODAN_STYLE_KEY = 'sb_shodan_style_v1';
|
||||
|
||||
const DEFAULT_STYLE: ShodanStyleConfig = { shape: 'circle', color: '#16a34a', size: 'md' };
|
||||
|
||||
const SHAPE_OPTIONS: { value: ShodanMarkerShape; label: string; glyph: string }[] = [
|
||||
{ value: 'circle', label: 'Circle', glyph: '●' },
|
||||
{ value: 'triangle', label: 'Triangle', glyph: '▲' },
|
||||
{ value: 'diamond', label: 'Diamond', glyph: '◆' },
|
||||
{ value: 'square', label: 'Square', glyph: '■' },
|
||||
];
|
||||
|
||||
const SIZE_OPTIONS: { value: ShodanMarkerSize; label: string }[] = [
|
||||
{ value: 'sm', label: 'SM' },
|
||||
{ value: 'md', label: 'MD' },
|
||||
{ value: 'lg', label: 'LG' },
|
||||
];
|
||||
|
||||
const COLOR_SWATCHES = [
|
||||
'#16a34a', '#ef4444', '#3b82f6', '#06b6d4',
|
||||
'#f97316', '#eab308', '#ec4899', '#e2e8f0',
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onOpenSettings: () => void;
|
||||
onResultsChange: (results: ShodanSearchMatch[], queryLabel: string) => void;
|
||||
onSelectEntity: (entity: SelectedEntity | null) => void;
|
||||
onStyleChange: (style: ShodanStyleConfig) => void;
|
||||
currentResults: ShodanSearchMatch[];
|
||||
isMinimized?: boolean;
|
||||
onMinimizedChange?: (minimized: boolean) => void;
|
||||
/** When true the settings modal is open — status auto-refreshes on close. */
|
||||
settingsOpen?: boolean;
|
||||
}
|
||||
|
||||
function toSelectedEntity(match: ShodanSearchMatch): SelectedEntity {
|
||||
return {
|
||||
id: match.id,
|
||||
type: 'shodan_host',
|
||||
name: `${match.ip}${match.port ? `:${match.port}` : ''}`,
|
||||
extra: { ...match },
|
||||
};
|
||||
}
|
||||
|
||||
function fromHost(host: ShodanHost): ShodanSearchMatch {
|
||||
return {
|
||||
id: host.id,
|
||||
ip: host.ip,
|
||||
port: host.ports?.[0] ?? null,
|
||||
lat: host.lat,
|
||||
lng: host.lng,
|
||||
city: host.city,
|
||||
region_code: host.region_code,
|
||||
country_code: host.country_code,
|
||||
country_name: host.country_name,
|
||||
location_label: host.location_label,
|
||||
asn: host.asn,
|
||||
org: host.org,
|
||||
isp: host.isp,
|
||||
os: host.os,
|
||||
product: host.services?.[0]?.product ?? null,
|
||||
transport: host.services?.[0]?.transport ?? null,
|
||||
timestamp: host.services?.[0]?.timestamp ?? null,
|
||||
hostnames: host.hostnames,
|
||||
domains: host.domains,
|
||||
tags: host.tags,
|
||||
vulns: host.vulns,
|
||||
data_snippet: host.services?.[0]?.banner_excerpt ?? null,
|
||||
attribution: host.attribution,
|
||||
};
|
||||
}
|
||||
|
||||
function facetList(raw: string): string[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string, mime = 'application/json') {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function buildCsv(rows: ShodanSearchMatch[]): string {
|
||||
const headers = [
|
||||
'source',
|
||||
'attribution',
|
||||
'ip',
|
||||
'port',
|
||||
'country_code',
|
||||
'location_label',
|
||||
'org',
|
||||
'asn',
|
||||
'product',
|
||||
'transport',
|
||||
'timestamp',
|
||||
];
|
||||
const esc = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||
return [
|
||||
headers.join(','),
|
||||
...rows.map((row) =>
|
||||
[
|
||||
'Shodan',
|
||||
row.attribution || 'Data from Shodan',
|
||||
row.ip,
|
||||
row.port ?? '',
|
||||
row.country_code ?? '',
|
||||
row.location_label ?? '',
|
||||
row.org ?? '',
|
||||
row.asn ?? '',
|
||||
row.product ?? '',
|
||||
row.transport ?? '',
|
||||
row.timestamp ?? '',
|
||||
]
|
||||
.map(esc)
|
||||
.join(','),
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export default function ShodanPanel({
|
||||
onOpenSettings,
|
||||
onResultsChange,
|
||||
onSelectEntity,
|
||||
onStyleChange,
|
||||
currentResults,
|
||||
isMinimized: isMinimizedProp,
|
||||
onMinimizedChange,
|
||||
settingsOpen,
|
||||
}: Props) {
|
||||
const [internalMinimized, setInternalMinimized] = useState(true);
|
||||
const isMinimized = isMinimizedProp !== undefined ? isMinimizedProp : internalMinimized;
|
||||
const setIsMinimized = (val: boolean | ((prev: boolean) => boolean)) => {
|
||||
const newVal = typeof val === 'function' ? val(isMinimized) : val;
|
||||
setInternalMinimized(newVal);
|
||||
onMinimizedChange?.(newVal);
|
||||
};
|
||||
const [mode, setMode] = useState<Mode>('search');
|
||||
const [status, setStatus] = useState<ShodanStatusResponse | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('port:443');
|
||||
const [page, setPage] = useState(1);
|
||||
const [facets, setFacets] = useState('country,port,org');
|
||||
const [hostIp, setHostIp] = useState('');
|
||||
const [presetLabel, setPresetLabel] = useState('');
|
||||
const [presets, setPresets] = useState<ShodanPreset[]>([]);
|
||||
const [countSummary, setCountSummary] = useState<ShodanCountResponse | null>(null);
|
||||
const [hostSummary, setHostSummary] = useState<ShodanHost | null>(null);
|
||||
const [styleConfig, setStyleConfig] = useState<ShodanStyleConfig>(DEFAULT_STYLE);
|
||||
const [customHex, setCustomHex] = useState('');
|
||||
const [lastAction, setLastAction] = useState<(() => void) | null>(null);
|
||||
const [unmappedCount, setUnmappedCount] = useState(0);
|
||||
const prevSettingsOpen = useRef(settingsOpen);
|
||||
const presetImportRef = useRef<HTMLInputElement | null>(null);
|
||||
const resultImportRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const next = await fetchShodanStatus();
|
||||
setStatus(next);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load Shodan status');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
// Auto-refresh status when settings modal closes (key may have changed)
|
||||
useEffect(() => {
|
||||
if (prevSettingsOpen.current && !settingsOpen) {
|
||||
void refreshStatus();
|
||||
}
|
||||
prevSettingsOpen.current = settingsOpen;
|
||||
}, [settingsOpen, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SHODAN_PRESETS_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
setPresets(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore bad local preset state
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(SHODAN_PRESETS_KEY, JSON.stringify(presets));
|
||||
}, [presets]);
|
||||
|
||||
// Load persisted style config
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SHODAN_STYLE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as ShodanStyleConfig;
|
||||
if (parsed && parsed.shape && parsed.color && parsed.size) {
|
||||
setStyleConfig(parsed);
|
||||
// Defer parent update to avoid setState-during-render
|
||||
queueMicrotask(() => onStyleChange(parsed));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const updateStyle = useCallback((patch: Partial<ShodanStyleConfig>) => {
|
||||
setStyleConfig((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
window.localStorage.setItem(SHODAN_STYLE_KEY, JSON.stringify(next));
|
||||
// Defer parent update out of the setState updater
|
||||
queueMicrotask(() => onStyleChange(next));
|
||||
return next;
|
||||
});
|
||||
}, [onStyleChange]);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setLastAction(() => () => void handleSearch());
|
||||
try {
|
||||
const resp = await searchShodan(query, page, facetList(facets));
|
||||
const mapped = resp.matches.filter((match) => match.lat != null && match.lng != null);
|
||||
setUnmappedCount(resp.matches.length - mapped.length);
|
||||
onResultsChange(mapped, resp.query);
|
||||
setCountSummary({
|
||||
ok: true,
|
||||
source: resp.source,
|
||||
attribution: resp.attribution,
|
||||
query: resp.query,
|
||||
total: resp.total,
|
||||
facets: resp.facets,
|
||||
note: resp.note,
|
||||
});
|
||||
setHostSummary(null);
|
||||
setLastAction(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Shodan search failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [facets, onResultsChange, page, query]);
|
||||
|
||||
const handleCount = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setLastAction(() => () => void handleCount());
|
||||
try {
|
||||
const resp = await countShodan(query, facetList(facets));
|
||||
setCountSummary(resp);
|
||||
setHostSummary(null);
|
||||
setLastAction(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Shodan count failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [facets, query]);
|
||||
|
||||
const handleHost = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setLastAction(() => () => void handleHost());
|
||||
try {
|
||||
const resp = await lookupShodanHost(hostIp);
|
||||
setHostSummary(resp.host);
|
||||
setCountSummary(null);
|
||||
const mapped = fromHost(resp.host);
|
||||
onResultsChange(
|
||||
resp.host.lat != null && resp.host.lng != null ? [mapped] : [],
|
||||
`HOST ${resp.host.ip}`,
|
||||
);
|
||||
onSelectEntity({
|
||||
id: mapped.id,
|
||||
type: 'shodan_host',
|
||||
name: `${mapped.ip}${mapped.port ? `:${mapped.port}` : ''}`,
|
||||
extra: { ...resp.host, ...mapped },
|
||||
});
|
||||
setLastAction(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Shodan host lookup failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [hostIp, onResultsChange, onSelectEntity]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onResultsChange([], '');
|
||||
onSelectEntity(null);
|
||||
setCountSummary(null);
|
||||
setHostSummary(null);
|
||||
setError(null);
|
||||
setLastAction(null);
|
||||
setUnmappedCount(0);
|
||||
}, [onResultsChange, onSelectEntity]);
|
||||
|
||||
const handleSavePreset = useCallback(() => {
|
||||
const label =
|
||||
presetLabel.trim() ||
|
||||
(mode === 'host' ? hostIp.trim() || 'Host Lookup' : query.trim() || 'Shodan Query');
|
||||
const preset: ShodanPreset = {
|
||||
id: `preset-${Date.now()}`,
|
||||
label,
|
||||
mode,
|
||||
query,
|
||||
page,
|
||||
facets,
|
||||
hostIp,
|
||||
style: { ...styleConfig },
|
||||
};
|
||||
setPresets((prev) => [preset, ...prev].slice(0, 16));
|
||||
setPresetLabel('');
|
||||
}, [facets, hostIp, mode, page, presetLabel, query, styleConfig]);
|
||||
|
||||
const applyPreset = useCallback((preset: ShodanPreset) => {
|
||||
setMode(preset.mode);
|
||||
setQuery(preset.query);
|
||||
setPage(preset.page);
|
||||
setFacets(preset.facets);
|
||||
setHostIp(preset.hostIp);
|
||||
if (preset.style) {
|
||||
updateStyle(preset.style);
|
||||
}
|
||||
}, [updateStyle]);
|
||||
|
||||
const removePreset = useCallback((id: string) => {
|
||||
setPresets((prev) => prev.filter((preset) => preset.id !== id));
|
||||
}, []);
|
||||
|
||||
const exportPresets = useCallback(() => {
|
||||
downloadText(
|
||||
`shadowbroker-shodan-presets-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
JSON.stringify({ source: 'ShadowBroker', type: 'shodan-presets', presets }, null, 2),
|
||||
);
|
||||
}, [presets]);
|
||||
|
||||
const importPresets = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as { presets?: ShodanPreset[] };
|
||||
const incoming = Array.isArray(parsed?.presets) ? parsed.presets : [];
|
||||
const sanitized = incoming
|
||||
.filter((preset) => preset && typeof preset.label === 'string')
|
||||
.map((preset) => ({
|
||||
id: preset.id || `preset-${Date.now()}-${Math.random()}`,
|
||||
label: String(preset.label || 'Imported Preset'),
|
||||
mode: (preset.mode === 'host' || preset.mode === 'count' ? preset.mode : 'search') as Mode,
|
||||
query: String(preset.query || ''),
|
||||
page: Math.max(1, Math.min(2, Number(preset.page) || 1)),
|
||||
facets: String(preset.facets || ''),
|
||||
hostIp: String(preset.hostIp || ''),
|
||||
}));
|
||||
setPresets((prev) => [...sanitized, ...prev].slice(0, 16));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to import Shodan presets');
|
||||
} finally {
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const exportResultsJson = useCallback(() => {
|
||||
if (!currentResults.length) return;
|
||||
downloadText(
|
||||
`shadowbroker-shodan-results-${new Date().toISOString().replace(/[:.]/g, '-')}.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
source: 'Shodan',
|
||||
attribution: 'Data from Shodan',
|
||||
exported_at: new Date().toISOString(),
|
||||
results: currentResults,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}, [currentResults]);
|
||||
|
||||
const exportResultsCsv = useCallback(() => {
|
||||
if (!currentResults.length) return;
|
||||
downloadText(
|
||||
`shadowbroker-shodan-results-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`,
|
||||
buildCsv(currentResults),
|
||||
'text/csv',
|
||||
);
|
||||
}, [currentResults]);
|
||||
|
||||
const importResults = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as { results?: ShodanSearchMatch[]; attribution?: string };
|
||||
const incoming = Array.isArray(parsed?.results) ? parsed.results : [];
|
||||
const sanitized = incoming
|
||||
.filter((row) => row && typeof row.ip === 'string')
|
||||
.map((row) => ({
|
||||
...row,
|
||||
id: String(row.id || `shodan-import-${row.ip}-${row.port || 'na'}`),
|
||||
ip: String(row.ip),
|
||||
port: row.port == null ? null : Number(row.port),
|
||||
lat: row.lat == null ? null : Number(row.lat),
|
||||
lng: row.lng == null ? null : Number(row.lng),
|
||||
hostnames: Array.isArray(row.hostnames) ? row.hostnames.map(String) : [],
|
||||
domains: Array.isArray(row.domains) ? row.domains.map(String) : [],
|
||||
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
|
||||
vulns: Array.isArray(row.vulns) ? row.vulns.map(String) : [],
|
||||
attribution: String(row.attribution || parsed?.attribution || 'Data from Shodan'),
|
||||
}))
|
||||
.filter((row) => row.lat != null && row.lng != null);
|
||||
onResultsChange(sanitized, 'IMPORTED RESULTS');
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to import Shodan results');
|
||||
} finally {
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
[onResultsChange],
|
||||
);
|
||||
|
||||
const resultSummary = useMemo(() => {
|
||||
if (hostSummary) {
|
||||
return `${hostSummary.ip} · ${hostSummary.location_label || 'unmapped'} · ${hostSummary.ports.length} ports`;
|
||||
}
|
||||
if (countSummary) {
|
||||
const unmappedNote = unmappedCount > 0 ? ` · ${unmappedCount} without coordinates` : '';
|
||||
return `${countSummary.total.toLocaleString()} matching hosts${unmappedNote}`;
|
||||
}
|
||||
if (currentResults.length) {
|
||||
const unmappedNote = unmappedCount > 0 ? ` · ${unmappedCount} without coordinates` : '';
|
||||
return `${currentResults.length.toLocaleString()} mapped results${unmappedNote}`;
|
||||
}
|
||||
return 'No local Shodan overlay loaded';
|
||||
}, [countSummary, currentResults.length, hostSummary, unmappedCount]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex-shrink-0 border border-green-700/40 bg-black/75 backdrop-blur-sm shadow-[0_0_18px_rgba(34,197,94,0.12)]">
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-green-700/30 bg-green-950/20 px-3 py-2 cursor-pointer"
|
||||
onClick={() => setIsMinimized((prev) => !prev)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar size={13} className="text-green-400" />
|
||||
<span className="text-[12px] font-mono font-bold tracking-[0.25em] text-green-400">
|
||||
SHODAN CONNECTOR
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[8px] font-mono">
|
||||
<span className="border border-green-700/40 px-1.5 py-0.5 text-green-300">
|
||||
{currentResults.length.toLocaleString()} MAP
|
||||
</span>
|
||||
<span className="border border-green-700/40 px-1.5 py-0.5 text-green-500/80">
|
||||
LOCAL
|
||||
</span>
|
||||
{isMinimized ? (
|
||||
<ChevronUp size={12} className="text-green-500" />
|
||||
) : (
|
||||
<ChevronDown size={12} className="text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMinimized && (
|
||||
<>
|
||||
<div className="border-b border-green-900/40 bg-green-950/10 px-3 py-2 text-[10px] font-mono leading-relaxed text-green-200/90">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle size={12} className="mt-0.5 text-green-400" />
|
||||
<div>
|
||||
<div className="font-bold tracking-wider text-green-400">PAID API / OPERATOR-SUPPLIED KEY</div>
|
||||
<div>
|
||||
Data from Shodan is fetched with the local <span className="text-green-400">SHODAN_API_KEY</span>,
|
||||
rendered as a temporary overlay, and remains the operator's responsibility.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<div className="mb-2 flex items-center gap-2 text-[9px] font-mono">
|
||||
{(['search', 'count', 'host'] as Mode[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
onClick={() => setMode(item)}
|
||||
className={`border px-2 py-1 tracking-[0.2em] transition-colors ${
|
||||
mode === item
|
||||
? 'border-green-500/50 bg-green-950/30 text-green-300'
|
||||
: 'border-green-900/40 text-green-600 hover:border-green-700/60 hover:text-green-400'
|
||||
}`}
|
||||
>
|
||||
{item.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={refreshStatus}
|
||||
className="ml-auto border border-green-900/40 px-2 py-1 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400"
|
||||
>
|
||||
STATUS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!status?.configured && (
|
||||
<div className="mb-3 border border-yellow-700/30 bg-yellow-950/10 px-3 py-2 text-[10px] font-mono text-yellow-300">
|
||||
<div className="mb-2 flex items-center gap-2 font-bold tracking-wide">
|
||||
<KeyRound size={12} /> SHODAN_API_KEY REQUIRED
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="border border-green-600/40 px-2 py-1 text-green-400 transition-colors hover:border-green-500/70"
|
||||
>
|
||||
OPEN SETTINGS
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-[10px] font-mono">
|
||||
{mode !== 'host' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={12} className="text-green-500" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={'query (e.g. port:443 org:"Amazon")'}
|
||||
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1.5 text-green-300 outline-none transition-colors focus:border-green-500/60"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={facets}
|
||||
onChange={(e) => setFacets(e.target.value)}
|
||||
placeholder="facets (country,port,org)"
|
||||
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1.5 text-green-300 outline-none transition-colors focus:border-green-500/60"
|
||||
/>
|
||||
{mode === 'search' && (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={2}
|
||||
value={page}
|
||||
onChange={(e) => setPage(Math.max(1, Math.min(2, Number(e.target.value) || 1)))}
|
||||
className="w-16 border border-green-900/50 bg-black/70 px-2 py-1.5 text-green-300 outline-none transition-colors focus:border-green-500/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Server size={12} className="text-green-500" />
|
||||
<input
|
||||
value={hostIp}
|
||||
onChange={(e) => setHostIp(e.target.value)}
|
||||
placeholder="host IP (e.g. 8.8.8.8)"
|
||||
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1.5 text-green-300 outline-none transition-colors focus:border-green-500/60"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-[9px] font-mono">
|
||||
{mode === 'search' && (
|
||||
<button
|
||||
onClick={() => void handleSearch()}
|
||||
disabled={busy || !status?.configured}
|
||||
className="border border-green-600/40 px-2.5 py-1.5 text-green-400 transition-colors hover:border-green-500/70 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
SEARCH / MAP
|
||||
</button>
|
||||
)}
|
||||
{mode === 'count' && (
|
||||
<button
|
||||
onClick={() => void handleCount()}
|
||||
disabled={busy || !status?.configured}
|
||||
className="border border-green-600/40 px-2.5 py-1.5 text-green-400 transition-colors hover:border-green-500/70 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
COUNT / FACETS
|
||||
</button>
|
||||
)}
|
||||
{mode === 'host' && (
|
||||
<button
|
||||
onClick={() => void handleHost()}
|
||||
disabled={busy || !status?.configured}
|
||||
className="border border-green-600/40 px-2.5 py-1.5 text-green-400 transition-colors hover:border-green-500/70 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
LOOKUP / MAP
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="border border-green-900/40 px-2.5 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400"
|
||||
>
|
||||
CLEAR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Marker Style Configurator ── */}
|
||||
<div className="mt-3 border border-green-900/40 bg-black/80 px-3 py-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[9px] font-mono tracking-[0.22em] text-green-500">MARKER STYLE</span>
|
||||
<span className="text-[14px] leading-none" style={{ color: styleConfig.color }}>
|
||||
{SHAPE_OPTIONS.find((s) => s.value === styleConfig.shape)?.glyph ?? '●'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Shape */}
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 text-[8px] font-mono tracking-widest text-green-600">SHAPE</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{SHAPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => updateStyle({ shape: opt.value })}
|
||||
className={`flex items-center justify-center w-8 h-7 border text-[13px] transition-colors ${
|
||||
styleConfig.shape === opt.value
|
||||
? 'border-green-500/60 bg-green-950/40 text-green-300'
|
||||
: 'border-green-900/40 text-green-700 hover:border-green-700/60 hover:text-green-400'
|
||||
}`}
|
||||
title={opt.label}
|
||||
>
|
||||
{opt.glyph}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 text-[8px] font-mono tracking-widest text-green-600">COLOR</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{COLOR_SWATCHES.map((hex) => (
|
||||
<button
|
||||
key={hex}
|
||||
onClick={() => { updateStyle({ color: hex }); setCustomHex(''); }}
|
||||
className={`w-5 h-5 border transition-all ${
|
||||
styleConfig.color === hex && !customHex
|
||||
? 'border-white scale-110'
|
||||
: 'border-green-900/40 hover:border-green-600/60'
|
||||
}`}
|
||||
style={{ backgroundColor: hex }}
|
||||
title={hex}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
value={customHex}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setCustomHex(v);
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(v)) {
|
||||
updateStyle({ color: v });
|
||||
}
|
||||
}}
|
||||
placeholder="#hex"
|
||||
maxLength={7}
|
||||
className="w-16 border border-green-900/50 bg-black/70 px-1.5 py-0.5 text-[9px] font-mono text-green-300 outline-none focus:border-green-500/60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size */}
|
||||
<div>
|
||||
<div className="mb-1 text-[8px] font-mono tracking-widest text-green-600">SIZE</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{SIZE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => updateStyle({ size: opt.value })}
|
||||
className={`px-2.5 py-1 border text-[9px] font-mono tracking-wider transition-colors ${
|
||||
styleConfig.size === opt.value
|
||||
? 'border-green-500/60 bg-green-950/40 text-green-300'
|
||||
: 'border-green-900/40 text-green-700 hover:border-green-700/60 hover:text-green-400'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 border border-green-900/40 bg-black/80 px-3 py-2">
|
||||
<div className="mb-2 text-[9px] font-mono tracking-[0.22em] text-green-500">PRESETS / EXPORT</div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<input
|
||||
value={presetLabel}
|
||||
onChange={(e) => setPresetLabel(e.target.value)}
|
||||
placeholder="preset label"
|
||||
className="flex-1 border border-green-900/50 bg-black/70 px-2 py-1.5 text-[10px] text-green-300 outline-none transition-colors focus:border-green-500/60"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSavePreset}
|
||||
className="border border-green-600/40 px-2 py-1.5 text-[9px] font-mono text-green-400 transition-colors hover:border-green-500/70"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Save size={10} /> SAVE
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[9px] font-mono">
|
||||
<button
|
||||
onClick={exportPresets}
|
||||
disabled={!presets.length}
|
||||
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400 disabled:opacity-40"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Download size={10} /> EXPORT PRESETS
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => presetImportRef.current?.click()}
|
||||
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Upload size={10} /> IMPORT PRESETS
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={exportResultsJson}
|
||||
disabled={!currentResults.length}
|
||||
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400 disabled:opacity-40"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Download size={10} /> RESULTS JSON
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={exportResultsCsv}
|
||||
disabled={!currentResults.length}
|
||||
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400 disabled:opacity-40"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Download size={10} /> RESULTS CSV
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => resultImportRef.current?.click()}
|
||||
className="border border-green-900/40 px-2 py-1.5 text-green-600 transition-colors hover:border-green-700/60 hover:text-green-400"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Upload size={10} /> IMPORT RESULTS
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={presetImportRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => void importPresets(e)}
|
||||
/>
|
||||
<input
|
||||
ref={resultImportRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => void importResults(e)}
|
||||
/>
|
||||
</div>
|
||||
{presets.length > 0 && (
|
||||
<div className="mt-3 max-h-32 space-y-1 overflow-y-auto styled-scrollbar">
|
||||
{presets.map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="flex items-center justify-between border border-green-950/40 bg-green-950/10 px-2 py-1.5"
|
||||
>
|
||||
<button
|
||||
onClick={() => applyPreset(preset)}
|
||||
className="min-w-0 flex-1 truncate text-left text-[10px] font-mono text-green-300 transition-colors hover:text-green-200"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removePreset(preset.id)}
|
||||
className="ml-2 text-[9px] font-mono text-green-700/70 transition-colors hover:text-red-300"
|
||||
>
|
||||
DELETE
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 border border-green-900/40 bg-black/80 px-3 py-2 text-[10px] font-mono">
|
||||
<div className="mb-1 flex items-center gap-2 text-green-500">
|
||||
<ShieldAlert size={12} />
|
||||
<span className="tracking-[0.25em]">SESSION STATUS</span>
|
||||
</div>
|
||||
<div className="text-green-300/90">{resultSummary}</div>
|
||||
{status?.warning && <div className="mt-1 text-green-500/80">{status.warning}</div>}
|
||||
{error && (
|
||||
<div className="mt-2 flex items-center justify-between border border-red-900/40 bg-red-950/20 px-2 py-1.5 text-red-300">
|
||||
<span>{error}</span>
|
||||
{lastAction && (
|
||||
<button
|
||||
onClick={() => { setError(null); lastAction(); }}
|
||||
disabled={busy}
|
||||
className="ml-2 inline-flex shrink-0 items-center gap-1 border border-red-700/40 px-1.5 py-0.5 text-[9px] font-mono text-red-300 transition-colors hover:border-red-500/60 hover:text-red-200 disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw size={9} /> RETRY
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{countSummary && (
|
||||
<div className="mt-3 max-h-40 space-y-2 overflow-y-auto border border-green-900/40 bg-black/80 p-3 styled-scrollbar">
|
||||
<div className="text-[9px] font-mono tracking-[0.22em] text-green-500">FACETS</div>
|
||||
{Object.entries(countSummary.facets).length === 0 ? (
|
||||
<div className="text-[10px] font-mono text-green-300/80">No facet buckets returned.</div>
|
||||
) : (
|
||||
Object.entries(countSummary.facets).map(([name, buckets]) => (
|
||||
<div key={name}>
|
||||
<div className="mb-1 text-[9px] font-mono text-green-400">{name.toUpperCase()}</div>
|
||||
<div className="space-y-1">
|
||||
{buckets.map((bucket) => (
|
||||
<div key={`${name}-${bucket.value}`} className="flex items-center justify-between text-[10px] font-mono text-green-300/90">
|
||||
<span className="truncate pr-3">{bucket.value || 'UNKNOWN'}</span>
|
||||
<span>{bucket.count.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hostSummary && (
|
||||
<div className="mt-3 max-h-40 overflow-y-auto border border-green-900/40 bg-black/80 p-3 styled-scrollbar text-[10px] font-mono">
|
||||
<div className="mb-2 flex items-center justify-between text-green-400">
|
||||
<span>{hostSummary.ip}</span>
|
||||
<span>{hostSummary.location_label || 'UNMAPPED'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-green-300/90">
|
||||
<span>ORG</span>
|
||||
<span className="text-right">{hostSummary.org || 'UNKNOWN'}</span>
|
||||
<span>ASN</span>
|
||||
<span className="text-right">{hostSummary.asn || 'UNKNOWN'}</span>
|
||||
<span>ISP</span>
|
||||
<span className="text-right">{hostSummary.isp || 'UNKNOWN'}</span>
|
||||
<span>PORTS</span>
|
||||
<span className="text-right">{hostSummary.ports.slice(0, 8).join(', ') || 'NONE'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentResults.length > 0 && (
|
||||
<div className="mt-3 max-h-44 overflow-y-auto border border-green-900/40 bg-black/80 p-2 styled-scrollbar">
|
||||
<div className="mb-2 flex items-center justify-between text-[9px] font-mono text-green-500">
|
||||
<span className="tracking-[0.22em]">MAPPED HOSTS</span>
|
||||
<span>{currentResults.length.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{currentResults.slice(0, 12).map((match) => (
|
||||
<button
|
||||
key={match.id}
|
||||
onClick={() => onSelectEntity(toSelectedEntity(match))}
|
||||
className="flex w-full items-center justify-between border border-green-950/40 bg-green-950/10 px-2 py-1.5 text-left transition-colors hover:border-green-700/60 hover:bg-green-950/20"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[10px] font-mono text-green-300">
|
||||
{match.ip}
|
||||
{match.port ? `:${match.port}` : ''}
|
||||
</div>
|
||||
<div className="truncate text-[9px] font-mono text-green-600">
|
||||
{match.location_label || match.org || 'UNMAPPED'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 shrink-0 text-[8px] font-mono text-green-500">
|
||||
{match.product || match.transport || 'HOST'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ExternalImage from '@/components/ExternalImage';
|
||||
|
||||
// Module-level cache: Wikipedia article title → thumbnail URL
|
||||
const _cache: Record<string, { url: string | null; done: boolean }> = {};
|
||||
@@ -7,63 +8,75 @@ const _cache: Record<string, { url: string | null; done: boolean }> = {};
|
||||
/**
|
||||
* WikiImage — displays a Wikipedia thumbnail for a given article URL.
|
||||
* Uses the Wikipedia REST API with a module-level cache (only fetches once per article).
|
||||
*
|
||||
*
|
||||
* Props:
|
||||
* wikiUrl: Full Wikipedia URL, e.g. "https://en.wikipedia.org/wiki/Boeing_787_Dreamliner"
|
||||
* label: Alt text / label for the image link
|
||||
* 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-52', accent = 'hover:border-cyan-500/50' }: {
|
||||
wikiUrl: string;
|
||||
label?: string;
|
||||
maxH?: string;
|
||||
accent?: string;
|
||||
export default function WikiImage({
|
||||
wikiUrl,
|
||||
label,
|
||||
maxH = 'max-h-52',
|
||||
accent = 'hover:border-cyan-500/50',
|
||||
}: {
|
||||
wikiUrl: string;
|
||||
label?: string;
|
||||
maxH?: string;
|
||||
accent?: string;
|
||||
}) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Extract article title from URL
|
||||
const title = wikiUrl.replace(/^https?:\/\/[^/]+\/wiki\//, '');
|
||||
// Extract article title from URL
|
||||
const title = wikiUrl.replace(/^https?:\/\/[^/]+\/wiki\//, '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!title || _cache[title]?.done) return;
|
||||
if (_cache[title]) return; // In-flight
|
||||
_cache[title] = { url: null, done: false };
|
||||
useEffect(() => {
|
||||
if (!title || _cache[title]?.done) return;
|
||||
if (_cache[title]) return; // In-flight
|
||||
_cache[title] = { url: null, done: false };
|
||||
|
||||
fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
_cache[title] = { url: d.thumbnail?.source || d.originalimage?.source || null, done: true };
|
||||
forceUpdate(n => n + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
_cache[title] = { url: null, done: true };
|
||||
forceUpdate(n => n + 1);
|
||||
});
|
||||
}, [title]);
|
||||
fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
_cache[title] = { url: d.thumbnail?.source || d.originalimage?.source || null, done: true };
|
||||
forceUpdate((n) => n + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
_cache[title] = { url: null, done: true };
|
||||
forceUpdate((n) => n + 1);
|
||||
});
|
||||
}, [title]);
|
||||
|
||||
const cached = _cache[title];
|
||||
const imgUrl = cached?.url;
|
||||
const loading = cached && !cached.done;
|
||||
const cached = _cache[title];
|
||||
const imgUrl = cached?.url;
|
||||
const loading = cached && !cached.done;
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
{loading && (
|
||||
<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-contain rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<a href={wikiUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline mt-1 inline-block font-mono">
|
||||
📖 {label || title.replace(/_/g, ' ')} — Wikipedia →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="pb-2">
|
||||
{loading && (
|
||||
<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">
|
||||
<ExternalImage
|
||||
src={imgUrl}
|
||||
alt={label || title.replace(/_/g, ' ')}
|
||||
width={640}
|
||||
height={360}
|
||||
className={`w-full h-auto ${maxH} object-contain rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`}
|
||||
style={{ width: '100%', height: 'auto' }}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={wikiUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline mt-1 inline-block font-mono"
|
||||
>
|
||||
📖 {label || title.replace(/_/g, ' ')} — Wikipedia →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,128 +1,148 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { MapEffects } from "@/types/dashboard";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { MapEffects } from '@/types/dashboard';
|
||||
|
||||
const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, setEffects, setUiVisible }: { effects: MapEffects; setEffects: (e: MapEffects) => void; setUiVisible: (v: boolean) => void }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState({ date: "XXXX-XX-XX", time: "00:00:00" });
|
||||
const WorldviewRightPanel = React.memo(function WorldviewRightPanel({
|
||||
effects,
|
||||
setEffects,
|
||||
setUiVisible,
|
||||
}: {
|
||||
effects: MapEffects;
|
||||
setEffects: (e: MapEffects) => void;
|
||||
setUiVisible: (v: boolean) => void;
|
||||
}) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState({ date: 'XXXX-XX-XX', time: '00:00:00' });
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
setCurrentTime({
|
||||
date: now.toISOString().slice(0, 10),
|
||||
time: now.toISOString().slice(11, 19)
|
||||
});
|
||||
};
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
setCurrentTime({
|
||||
date: now.toISOString().slice(0, 10),
|
||||
time: now.toISOString().slice(11, 19),
|
||||
});
|
||||
};
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
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]'}`}
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className={`w-full bg-[var(--bg-primary)]/40 backdrop-blur-sm border border-[var(--border-primary)] z-10 flex flex-col font-mono 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-[var(--border-primary)] bg-[var(--bg-primary)]/40 backdrop-blur-sm px-4 py-2 rounded-sm relative 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-[var(--text-secondary)] tracking-wider">
|
||||
REC {currentTime.date} {currentTime.time}
|
||||
<br />
|
||||
ORB: 47696 PASS: DESC-284
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side controls box */}
|
||||
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-sm border border-[var(--border-primary)] pointer-events-auto border-r-2 border-r-[var(--border-primary)] flex flex-col relative overflow-hidden h-full">
|
||||
{/* Header / Toggle */}
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
{/* Record / Orbit Tracker Header */}
|
||||
<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-[var(--text-secondary)] tracking-wider">
|
||||
REC {currentTime.date} {currentTime.time}
|
||||
<br />
|
||||
ORB: 47696 PASS: DESC-284
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Right side controls box */}
|
||||
<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-[var(--border-primary)] flex flex-col relative overflow-hidden h-full">
|
||||
|
||||
{/* Header / Toggle */}
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-y-auto styled-scrollbar"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-4 pt-4">
|
||||
{/* Bloom Toggle */}
|
||||
<div
|
||||
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)}
|
||||
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 })}
|
||||
>
|
||||
<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 className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-[var(--text-muted)]'}`}
|
||||
>
|
||||
✧
|
||||
</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-[var(--text-muted)]">
|
||||
{effects.bloom ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-y-auto styled-scrollbar"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-4 pt-4">
|
||||
{/* Sharpen Slider */}
|
||||
<div className="flex flex-col gap-3 group border border-[var(--border-primary)]/50 bg-[var(--bg-secondary)]/10 rounded px-4 py-3 pb-4 relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-cyan-500"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full border border-cyan-400 flex items-center justify-center relative">
|
||||
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full"></span>
|
||||
</span>
|
||||
<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-[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>
|
||||
<span className="text-[9px] font-mono text-cyan-400">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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-[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-[var(--text-muted)]'}`}>✧</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-[var(--text-muted)]">{effects.bloom ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
{/* HUD Dropdown */}
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<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-[var(--border-secondary)] rounded-full flex items-center justify-center"></span>
|
||||
<span className="text-xs font-mono tracking-widest">HUD</span>
|
||||
</div>
|
||||
|
||||
{/* Sharpen Slider */}
|
||||
<div className="flex flex-col gap-3 group border border-[var(--border-primary)]/50 bg-[var(--bg-secondary)]/10 rounded px-4 py-3 pb-4 relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-cyan-500"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full border border-cyan-400 flex items-center justify-center relative">
|
||||
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full"></span>
|
||||
</span>
|
||||
<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-[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>
|
||||
<span className="text-[9px] font-mono text-cyan-400">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* HUD Dropdown */}
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<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-[var(--border-secondary)] 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-[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>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full border border-red-900/30 bg-red-950/10 rounded py-3 mt-2 text-[10px] font-mono tracking-widest text-red-500 hover:text-white hover:bg-red-900 hover:border-red-600 transition-all font-bold"
|
||||
onClick={() => setUiVisible(false)}
|
||||
>
|
||||
CLEAR UI (TACTICAL MODE)
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
<button
|
||||
className="w-full border border-red-900/30 bg-red-950/10 rounded py-3 mt-2 text-[10px] font-mono tracking-widest text-red-500 hover:text-white hover:bg-red-900 hover:border-red-600 transition-all font-bold"
|
||||
onClick={() => setUiVisible(false)}
|
||||
>
|
||||
CLEAR UI (TACTICAL MODE)
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorldviewRightPanel;
|
||||
|
||||
@@ -1,307 +1,468 @@
|
||||
import React from "react";
|
||||
import { Marker } from "react-map-gl/maplibre";
|
||||
import type { ViewState } from "react-map-gl/maplibre";
|
||||
import React from 'react';
|
||||
import { Marker } from 'react-map-gl/maplibre';
|
||||
import type { Earthquake, SelectedEntity, Ship, TrackedFlight, UAV } from '@/types/dashboard';
|
||||
import type { SpreadAlertItem } from '@/utils/alertSpread';
|
||||
|
||||
// Shared monospace label style base
|
||||
const LABEL_BASE: React.CSSProperties = {
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
|
||||
const LABEL_SHADOW_EXTRA = '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000';
|
||||
|
||||
// -- Cluster count label (ships / earthquakes) --
|
||||
export function ClusterCountLabels({ clusters, prefix }: { clusters: any[]; prefix: string }) {
|
||||
return (
|
||||
<>
|
||||
{clusters.map((c: any) => (
|
||||
<Marker key={`${prefix}-${c.id}`} longitude={c.lng} latitude={c.lat} anchor="center" style={{ zIndex: 1 }}>
|
||||
<div style={{ ...LABEL_BASE, color: '#fff', fontSize: '11px', textAlign: 'center' }}>
|
||||
{c.count}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
type ClusterPoint = {
|
||||
id: string | number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
count: string | number;
|
||||
};
|
||||
|
||||
export function ClusterCountLabels({ clusters, prefix }: { clusters: ClusterPoint[]; prefix: string }) {
|
||||
return (
|
||||
<>
|
||||
{clusters.map((c) => (
|
||||
<Marker
|
||||
key={`${prefix}-${c.id}`}
|
||||
longitude={c.lng}
|
||||
latitude={c.lat}
|
||||
anchor="center"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<div style={{ ...LABEL_BASE, color: '#fff', fontSize: '11px', textAlign: 'center' }}>
|
||||
{c.count}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Tracked flights labels --
|
||||
const TRACKED_LABEL_COLOR_MAP: Record<string, string> = {
|
||||
'#ff1493': '#ff1493', pink: '#ff1493', red: '#ff4444',
|
||||
blue: '#3b82f6', orange: '#FF8C00', '#32cd32': '#32cd32',
|
||||
purple: '#b266ff', white: '#cccccc',
|
||||
'#ff1493': '#ff1493',
|
||||
pink: '#ff1493',
|
||||
red: '#ff4444',
|
||||
blue: '#3b82f6',
|
||||
orange: '#FF8C00',
|
||||
'#32cd32': '#32cd32',
|
||||
purple: '#b266ff',
|
||||
white: '#cccccc',
|
||||
};
|
||||
|
||||
interface TrackedFlightLabelsProps {
|
||||
flights: any[];
|
||||
viewState: ViewState;
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
interpFlight: (f: any) => [number, number];
|
||||
flights: TrackedFlight[];
|
||||
zoom: number;
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
interpFlight: (f: TrackedFlight) => [number, number];
|
||||
}
|
||||
|
||||
export function TrackedFlightLabels({ flights, viewState, inView, interpFlight }: TrackedFlightLabelsProps) {
|
||||
return (
|
||||
<>
|
||||
{flights.map((f: any, i: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (!inView(f.lat, f.lng)) return null;
|
||||
export function TrackedFlightLabels({
|
||||
flights,
|
||||
zoom,
|
||||
inView,
|
||||
interpFlight,
|
||||
}: TrackedFlightLabelsProps) {
|
||||
return (
|
||||
<>
|
||||
{flights.map((f, i) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (!inView(f.lat, f.lng)) return null;
|
||||
|
||||
const alertColor = f.alert_color || '#ff1493';
|
||||
if (alertColor === 'yellow' || alertColor === 'black') return null;
|
||||
const alertColor = f.alert_color || '#ff1493';
|
||||
if (alertColor === 'yellow' || alertColor === 'black') return null;
|
||||
|
||||
const isHighPriority = alertColor === '#ff1493' || alertColor === 'pink' || alertColor === 'red';
|
||||
if (!isHighPriority && viewState.zoom < 5) return null;
|
||||
const isHighPriority =
|
||||
alertColor === '#ff1493' || alertColor === 'pink' || alertColor === 'red';
|
||||
if (!isHighPriority && zoom < 5) return null;
|
||||
|
||||
let displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN";
|
||||
if (displayName === 'Private' || displayName === 'private') return null;
|
||||
const displayName =
|
||||
f.alert_operator ||
|
||||
f.operator ||
|
||||
f.owner ||
|
||||
f.name ||
|
||||
f.callsign ||
|
||||
f.icao24 ||
|
||||
'UNKNOWN';
|
||||
if (displayName === 'Private' || displayName === 'private') return null;
|
||||
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
const labelColor = grounded ? '#888' : (TRACKED_LABEL_COLOR_MAP[alertColor] || alertColor);
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
const labelColor = grounded ? '#888' : TRACKED_LABEL_COLOR_MAP[alertColor] || alertColor;
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
|
||||
return (
|
||||
<Marker key={`tf-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 10]} style={{ zIndex: 2 }}>
|
||||
<div style={{ ...LABEL_BASE, color: labelColor, fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
|
||||
{String(displayName)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<Marker
|
||||
key={`tf-label-${i}`}
|
||||
longitude={iLng}
|
||||
latitude={iLat}
|
||||
anchor="top"
|
||||
offset={[0, 10]}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...LABEL_BASE,
|
||||
color: labelColor,
|
||||
fontSize: '10px',
|
||||
textShadow: LABEL_SHADOW_EXTRA,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{String(displayName)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Carrier labels --
|
||||
interface CarrierLabelsProps {
|
||||
ships: any[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
interpShip: (s: any) => [number, number];
|
||||
ships: Ship[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
interpShip: (s: Ship) => [number, number];
|
||||
}
|
||||
|
||||
export function CarrierLabels({ ships, inView, interpShip }: CarrierLabelsProps) {
|
||||
return (
|
||||
<>
|
||||
{ships.map((s: any, i: number) => {
|
||||
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
|
||||
if (!inView(s.lat, s.lng)) return null;
|
||||
const [iLng, iLat] = interpShip(s);
|
||||
return (
|
||||
<Marker key={`carrier-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
|
||||
<div style={{ ...LABEL_BASE, textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap', textAlign: 'center' }}>
|
||||
<div style={{ color: '#ffaa00', fontSize: '11px', fontWeight: 'bold' }}>
|
||||
[[{s.name}]]
|
||||
</div>
|
||||
{s.estimated && (
|
||||
<div style={{ color: '#ff6644', fontSize: '8px', letterSpacing: '1.5px' }}>
|
||||
EST. POSITION — OSINT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{ships.map((s, i) => {
|
||||
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
|
||||
if (!inView(s.lat, s.lng)) return null;
|
||||
const [iLng, iLat] = interpShip(s);
|
||||
return (
|
||||
<Marker
|
||||
key={`carrier-label-${i}`}
|
||||
longitude={iLng}
|
||||
latitude={iLat}
|
||||
anchor="top"
|
||||
offset={[0, 12]}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...LABEL_BASE,
|
||||
textShadow: LABEL_SHADOW_EXTRA,
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#ffaa00', fontSize: '11px', fontWeight: 'bold' }}>
|
||||
[[{s.name}]]
|
||||
</div>
|
||||
{s.estimated && (
|
||||
<div style={{ color: '#ff6644', fontSize: '8px', letterSpacing: '1.5px' }}>
|
||||
EST. POSITION — OSINT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Tracked yacht labels --
|
||||
interface TrackedYachtLabelsProps {
|
||||
ships: any[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
interpShip: (s: any) => [number, number];
|
||||
ships: Ship[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
interpShip: (s: Ship) => [number, number];
|
||||
}
|
||||
|
||||
export function TrackedYachtLabels({ ships, inView, interpShip }: TrackedYachtLabelsProps) {
|
||||
return (
|
||||
<>
|
||||
{ships.map((s: any, i: number) => {
|
||||
if (!s.yacht_alert || s.lat == null || s.lng == null) return null;
|
||||
if (!inView(s.lat, s.lng)) return null;
|
||||
const [iLng, iLat] = interpShip(s);
|
||||
return (
|
||||
<Marker key={`yacht-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
|
||||
<div style={{ ...LABEL_BASE, color: s.yacht_color || '#FF69B4', fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
|
||||
{s.yacht_owner || s.name || 'TRACKED YACHT'}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{ships.map((s, i) => {
|
||||
if (!s.yacht_alert || s.lat == null || s.lng == null) return null;
|
||||
if (!inView(s.lat, s.lng)) return null;
|
||||
const [iLng, iLat] = interpShip(s);
|
||||
return (
|
||||
<Marker
|
||||
key={`yacht-label-${i}`}
|
||||
longitude={iLng}
|
||||
latitude={iLat}
|
||||
anchor="top"
|
||||
offset={[0, 12]}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...LABEL_BASE,
|
||||
color: s.yacht_color || '#FF69B4',
|
||||
fontSize: '10px',
|
||||
textShadow: LABEL_SHADOW_EXTRA,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{s.yacht_owner || s.name || 'TRACKED YACHT'}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- UAV labels --
|
||||
interface UavLabelsProps {
|
||||
uavs: any[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
uavs: UAV[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
}
|
||||
|
||||
export function UavLabels({ uavs, inView }: UavLabelsProps) {
|
||||
return (
|
||||
<>
|
||||
{uavs.map((uav: any, i: number) => {
|
||||
if (uav.lat == null || uav.lng == null) return null;
|
||||
if (!inView(uav.lat, uav.lng)) return null;
|
||||
const name = uav.aircraft_model ? `[UAV: ${uav.aircraft_model}]` : `[UAV: ${uav.callsign}]`;
|
||||
return (
|
||||
<Marker key={`uav-label-${i}`} longitude={uav.lng} latitude={uav.lat} anchor="top" offset={[0, 10]} style={{ zIndex: 2 }}>
|
||||
<div style={{ ...LABEL_BASE, color: '#ff8c00', fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{uavs.map((uav, i) => {
|
||||
if (uav.lat == null || uav.lng == null) return null;
|
||||
if (!inView(uav.lat, uav.lng)) return null;
|
||||
const name = uav.aircraft_model ? `[UAV: ${uav.aircraft_model}]` : `[UAV: ${uav.callsign}]`;
|
||||
return (
|
||||
<Marker
|
||||
key={`uav-label-${i}`}
|
||||
longitude={uav.lng}
|
||||
latitude={uav.lat}
|
||||
anchor="top"
|
||||
offset={[0, 10]}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...LABEL_BASE,
|
||||
color: '#ff8c00',
|
||||
fontSize: '10px',
|
||||
textShadow: LABEL_SHADOW_EXTRA,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Earthquake labels --
|
||||
interface EarthquakeLabelsProps {
|
||||
earthquakes: any[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
earthquakes: Earthquake[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
}
|
||||
|
||||
export function EarthquakeLabels({ earthquakes, inView }: EarthquakeLabelsProps) {
|
||||
return (
|
||||
<>
|
||||
{earthquakes.map((eq: any, i: number) => {
|
||||
if (eq.lat == null || eq.lng == null) return null;
|
||||
if (!inView(eq.lat, eq.lng)) return null;
|
||||
return (
|
||||
<Marker key={`eq-label-${i}`} longitude={eq.lng} latitude={eq.lat} anchor="top" offset={[0, 14]} style={{ zIndex: 1 }}>
|
||||
<div style={{ ...LABEL_BASE, color: '#ffcc00', fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
|
||||
[M{eq.mag}] {eq.place || ''}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{earthquakes.map((eq, i) => {
|
||||
if (eq.lat == null || eq.lng == null) return null;
|
||||
if (!inView(eq.lat, eq.lng)) return null;
|
||||
return (
|
||||
<Marker
|
||||
key={`eq-label-${i}`}
|
||||
longitude={eq.lng}
|
||||
latitude={eq.lat}
|
||||
anchor="top"
|
||||
offset={[0, 14]}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...LABEL_BASE,
|
||||
color: '#ffcc00',
|
||||
fontSize: '10px',
|
||||
textShadow: LABEL_SHADOW_EXTRA,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
[M{eq.mag}] {eq.place || ''}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Threat alert markers --
|
||||
function getRiskColor(score: number): string {
|
||||
if (score >= 9) return '#ef4444';
|
||||
if (score >= 7) return '#f97316';
|
||||
if (score >= 4) return '#eab308';
|
||||
if (score >= 1) return '#3b82f6';
|
||||
return '#22c55e';
|
||||
if (score >= 9) return '#ef4444';
|
||||
if (score >= 7) return '#f97316';
|
||||
if (score >= 4) return '#eab308';
|
||||
if (score >= 1) return '#3b82f6';
|
||||
return '#22c55e';
|
||||
}
|
||||
|
||||
interface ThreatMarkerProps {
|
||||
spreadAlerts: any[];
|
||||
viewState: ViewState;
|
||||
selectedEntity: any;
|
||||
onEntityClick?: (entity: { id: string | number; type: string } | null) => void;
|
||||
onDismiss?: (alertKey: string) => void;
|
||||
spreadAlerts: SpreadAlertItem[];
|
||||
zoom: number;
|
||||
selectedEntity: SelectedEntity | null;
|
||||
onEntityClick?: (entity: SelectedEntity | null) => void;
|
||||
onDismiss?: (alertKey: string) => void;
|
||||
}
|
||||
|
||||
export function ThreatMarkers({ spreadAlerts, viewState, selectedEntity, onEntityClick, onDismiss }: ThreatMarkerProps) {
|
||||
return (
|
||||
<>
|
||||
{spreadAlerts.map((n: any) => {
|
||||
const count = n.cluster_count || 1;
|
||||
const score = n.risk_score || 0;
|
||||
const riskColor = getRiskColor(score);
|
||||
const alertKey = n.alertKey || `${n.title}|${n.coords?.[0]},${n.coords?.[1]}`;
|
||||
export function ThreatMarkers({
|
||||
spreadAlerts,
|
||||
zoom,
|
||||
selectedEntity,
|
||||
onEntityClick,
|
||||
onDismiss,
|
||||
}: ThreatMarkerProps) {
|
||||
return (
|
||||
<>
|
||||
{spreadAlerts.map((n) => {
|
||||
const count = n.cluster_count || 1;
|
||||
const score = n.risk_score || 0;
|
||||
const riskColor = getRiskColor(score);
|
||||
const alertKey = n.alertKey || `${n.title}|${n.coords?.[0]},${n.coords?.[1]}`;
|
||||
|
||||
let isVisible = viewState.zoom >= 1;
|
||||
if (selectedEntity) {
|
||||
if (selectedEntity.type === 'news') {
|
||||
if (selectedEntity.id !== alertKey) isVisible = false;
|
||||
} else {
|
||||
isVisible = false;
|
||||
}
|
||||
}
|
||||
let isVisible = zoom >= 1;
|
||||
if (selectedEntity) {
|
||||
if (selectedEntity.type === 'news') {
|
||||
if (selectedEntity.id !== alertKey) isVisible = false;
|
||||
} else {
|
||||
isVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`threat-${alertKey}`}
|
||||
longitude={n.coords[1]}
|
||||
latitude={n.coords[0]}
|
||||
anchor="center"
|
||||
offset={[n.offsetX, n.offsetY]}
|
||||
style={{ zIndex: 50 + score }}
|
||||
onClick={(e) => {
|
||||
e.originalEvent.stopPropagation();
|
||||
onEntityClick?.({ id: alertKey, type: 'news' });
|
||||
}}
|
||||
>
|
||||
<div className="relative group/alert">
|
||||
{n.showLine && isVisible && (
|
||||
<svg className="absolute pointer-events-none" style={{ left: '50%', top: '50%', width: 1, height: 1, overflow: 'visible', zIndex: -1 }}>
|
||||
<line x1={0} y1={0} x2={-n.offsetX} y2={-n.offsetY} stroke={riskColor} strokeWidth="1.5" strokeDasharray="3,3" className="opacity-80" />
|
||||
<circle cx={-n.offsetX} cy={-n.offsetY} r="2" fill={riskColor} />
|
||||
</svg>
|
||||
)}
|
||||
return (
|
||||
<Marker
|
||||
key={`threat-${alertKey}`}
|
||||
longitude={n.coords[1]}
|
||||
latitude={n.coords[0]}
|
||||
anchor="center"
|
||||
offset={[n.offsetX, n.offsetY]}
|
||||
style={{ zIndex: 50 + score }}
|
||||
onClick={(e) => {
|
||||
e.originalEvent.stopPropagation();
|
||||
onEntityClick?.({ id: alertKey, type: 'news' });
|
||||
}}
|
||||
>
|
||||
<div className="relative group/alert">
|
||||
{n.showLine && isVisible && (
|
||||
<svg
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: 1,
|
||||
height: 1,
|
||||
overflow: 'visible',
|
||||
zIndex: -1,
|
||||
}}
|
||||
>
|
||||
<line
|
||||
x1={0}
|
||||
y1={0}
|
||||
x2={-n.offsetX}
|
||||
y2={-n.offsetY}
|
||||
stroke={riskColor}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="3,3"
|
||||
className="opacity-80"
|
||||
/>
|
||||
<circle cx={-n.offsetX} cy={-n.offsetY} r="2" fill={riskColor} />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="cursor-pointer transition-all duration-300 relative"
|
||||
style={{
|
||||
opacity: isVisible ? 1.0 : 0.0,
|
||||
pointerEvents: isVisible ? 'auto' : 'none',
|
||||
backgroundColor: 'rgba(5, 5, 5, 0.95)',
|
||||
border: `1.5px solid ${riskColor}`,
|
||||
borderRadius: '4px',
|
||||
padding: '5px 16px 5px 8px',
|
||||
color: riskColor,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
boxShadow: `0 0 12px ${riskColor}60`,
|
||||
zIndex: 10,
|
||||
lineHeight: '1.2',
|
||||
minWidth: '120px'
|
||||
}}
|
||||
>
|
||||
{n.showLine && isVisible && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: n.offsetY < 0 ? `6px solid ${riskColor}` : 'none',
|
||||
borderBottom: n.offsetY > 0 ? `6px solid ${riskColor}` : 'none',
|
||||
left: '50%',
|
||||
[n.offsetY < 0 ? 'bottom' : 'top']: '-6px',
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="cursor-pointer transition-opacity duration-300 relative"
|
||||
style={{
|
||||
opacity: isVisible ? 1.0 : 0.0,
|
||||
pointerEvents: isVisible ? 'auto' : 'none',
|
||||
backgroundColor: 'rgba(5, 5, 5, 0.95)',
|
||||
border: `1.5px solid ${riskColor}`,
|
||||
borderRadius: '4px',
|
||||
padding: '5px 16px 5px 8px',
|
||||
color: riskColor,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
boxShadow: `0 0 12px ${riskColor}60`,
|
||||
zIndex: 10,
|
||||
lineHeight: '1.2',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
>
|
||||
{n.showLine && isVisible && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: n.offsetY < 0 ? `6px solid ${riskColor}` : 'none',
|
||||
borderBottom: n.offsetY > 0 ? `6px solid ${riskColor}` : 'none',
|
||||
left: '50%',
|
||||
[n.offsetY < 0 ? 'bottom' : 'top']: '-6px',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 border border-current rounded opacity-50 animate-pulse" style={{ color: riskColor, zIndex: -1 }}></div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDismiss(alertKey); }}
|
||||
style={{
|
||||
position: 'absolute', top: '2px', right: '4px',
|
||||
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
color: riskColor, fontSize: '12px', fontWeight: 'bold',
|
||||
lineHeight: 1, padding: '0 2px', opacity: 0.7, zIndex: 20,
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.7')}
|
||||
>×</button>
|
||||
)}
|
||||
<div style={{ fontSize: '10px', letterSpacing: '0.5px' }}>!! ALERT LVL {score} !!</div>
|
||||
<div style={{ color: '#fff', fontSize: '9px', marginTop: '2px', maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{n.title}
|
||||
</div>
|
||||
{count > 1 && (
|
||||
<div style={{ color: riskColor, opacity: 0.8, fontSize: '8px', marginTop: '2px' }}>
|
||||
[+{count - 1} ACTIVE THREATS IN AREA]
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
<div
|
||||
className="absolute inset-0 border border-current rounded opacity-50"
|
||||
style={{ color: riskColor, zIndex: -1 }}
|
||||
></div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(alertKey);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
right: '4px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: riskColor,
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 1,
|
||||
padding: '0 2px',
|
||||
opacity: 0.7,
|
||||
zIndex: 20,
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.7')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<div style={{ fontSize: '10px', letterSpacing: '0.5px' }}>
|
||||
!! ALERT LVL {score} !!
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: '9px',
|
||||
marginTop: '2px',
|
||||
maxWidth: '160px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{n.title}
|
||||
</div>
|
||||
{count > 1 && (
|
||||
<div
|
||||
style={{ color: riskColor, opacity: 0.8, fontSize: '8px', marginTop: '2px' }}
|
||||
>
|
||||
[+{count - 1} ACTIVE THREATS IN AREA]
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { interpolatePosition } from '@/utils/positioning';
|
||||
import { classifyAircraft } from '@/utils/aircraftClassification';
|
||||
import type { Flight, Ship, SigintSignal } from '@/types/dashboard';
|
||||
import type { FlightLayerConfig } from '@/components/map/geoJSONBuilders';
|
||||
|
||||
type BoundsTuple = [number, number, number, number];
|
||||
type FC = GeoJSON.FeatureCollection | null;
|
||||
|
||||
export type DynamicMapLayersPayload = {
|
||||
commercialFlights?: Flight[];
|
||||
privateFlights?: Flight[];
|
||||
privateJets?: Flight[];
|
||||
militaryFlights?: Flight[];
|
||||
trackedFlights?: Flight[];
|
||||
ships?: Ship[];
|
||||
sigint?: SigintSignal[];
|
||||
commConfig: FlightLayerConfig;
|
||||
privConfig: FlightLayerConfig;
|
||||
jetsConfig: FlightLayerConfig;
|
||||
milConfig: FlightLayerConfig;
|
||||
};
|
||||
|
||||
export type DynamicMapLayersDataPayload = DynamicMapLayersPayload;
|
||||
|
||||
export type DynamicMapLayersBuildPayload = {
|
||||
bounds: BoundsTuple;
|
||||
dtSeconds: number;
|
||||
trackedIcaos: string[];
|
||||
activeLayers: {
|
||||
flights: boolean;
|
||||
private: boolean;
|
||||
jets: boolean;
|
||||
military: boolean;
|
||||
tracked: boolean;
|
||||
ships_military: boolean;
|
||||
ships_cargo: boolean;
|
||||
ships_civilian: boolean;
|
||||
ships_passenger: boolean;
|
||||
ships_tracked_yachts: boolean;
|
||||
sigint_meshtastic: boolean;
|
||||
sigint_aprs: boolean;
|
||||
};
|
||||
activeFilters?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type DynamicMapLayersResult = {
|
||||
commercialFlightsGeoJSON: FC;
|
||||
privateFlightsGeoJSON: FC;
|
||||
privateJetsGeoJSON: FC;
|
||||
militaryFlightsGeoJSON: FC;
|
||||
trackedFlightsGeoJSON: FC;
|
||||
shipsGeoJSON: FC;
|
||||
meshtasticGeoJSON: FC;
|
||||
aprsGeoJSON: FC;
|
||||
};
|
||||
|
||||
type SyncRequest = {
|
||||
id: string;
|
||||
action: 'sync_dynamic_layers';
|
||||
payload: DynamicMapLayersDataPayload;
|
||||
};
|
||||
|
||||
type BuildRequest = {
|
||||
id: string;
|
||||
action: 'build_dynamic_layers';
|
||||
payload: DynamicMapLayersBuildPayload;
|
||||
};
|
||||
|
||||
type WorkerRequest = SyncRequest | BuildRequest;
|
||||
|
||||
type WorkerResponse = {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: DynamicMapLayersResult;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const EMPTY_RESULT: DynamicMapLayersResult = {
|
||||
commercialFlightsGeoJSON: null,
|
||||
privateFlightsGeoJSON: null,
|
||||
privateJetsGeoJSON: null,
|
||||
militaryFlightsGeoJSON: null,
|
||||
trackedFlightsGeoJSON: null,
|
||||
shipsGeoJSON: null,
|
||||
meshtasticGeoJSON: null,
|
||||
aprsGeoJSON: null,
|
||||
};
|
||||
|
||||
const UNBOUNDED_INTERP_SECONDS = Number.POSITIVE_INFINITY;
|
||||
|
||||
const TRACKED_GROUNDED_ICON_MAP: Record<string, string> = {
|
||||
airliner: 'svgAirlinerGrey',
|
||||
turboprop: 'svgTurbopropGrey',
|
||||
bizjet: 'svgBizjetGrey',
|
||||
heli: 'svgHeliGrey',
|
||||
};
|
||||
|
||||
const TRACKED_ICON_MAP: Record<string, Record<string, string>> = {
|
||||
heli: {
|
||||
'#ff1493': 'svgHeliPink',
|
||||
pink: 'svgHeliPink',
|
||||
red: 'svgHeliAlertRed',
|
||||
blue: 'svgHeliBlue',
|
||||
darkblue: 'svgHeliDarkBlue',
|
||||
yellow: 'svgHeli',
|
||||
orange: 'svgHeliOrange',
|
||||
purple: 'svgHeliPurple',
|
||||
'#32cd32': 'svgHeliLime',
|
||||
black: 'svgHeliBlack',
|
||||
white: 'svgHeliWhiteAlert',
|
||||
},
|
||||
airliner: {
|
||||
'#ff1493': 'svgAirlinerPink',
|
||||
pink: 'svgAirlinerPink',
|
||||
red: 'svgAirlinerRed',
|
||||
blue: 'svgAirlinerBlue',
|
||||
darkblue: 'svgAirlinerDarkBlue',
|
||||
yellow: 'svgAirlinerYellow',
|
||||
orange: 'svgAirlinerOrange',
|
||||
purple: 'svgAirlinerPurple',
|
||||
'#32cd32': 'svgAirlinerLime',
|
||||
black: 'svgAirlinerBlack',
|
||||
white: 'svgAirlinerWhite',
|
||||
},
|
||||
turboprop: {
|
||||
'#ff1493': 'svgTurbopropPink',
|
||||
pink: 'svgTurbopropPink',
|
||||
red: 'svgTurbopropRed',
|
||||
blue: 'svgTurbopropBlue',
|
||||
darkblue: 'svgTurbopropDarkBlue',
|
||||
yellow: 'svgTurbopropYellow',
|
||||
orange: 'svgTurbopropOrange',
|
||||
purple: 'svgTurbopropPurple',
|
||||
'#32cd32': 'svgTurbopropLime',
|
||||
black: 'svgTurbopropBlack',
|
||||
white: 'svgTurbopropWhite',
|
||||
},
|
||||
bizjet: {
|
||||
'#ff1493': 'svgBizjetPink',
|
||||
pink: 'svgBizjetPink',
|
||||
red: 'svgBizjetRed',
|
||||
blue: 'svgBizjetBlue',
|
||||
darkblue: 'svgBizjetDarkBlue',
|
||||
yellow: 'svgBizjetYellow',
|
||||
orange: 'svgBizjetOrange',
|
||||
purple: 'svgBizjetPurple',
|
||||
'#32cd32': 'svgBizjetLime',
|
||||
black: 'svgBizjetBlack',
|
||||
white: 'svgBizjetWhite',
|
||||
},
|
||||
};
|
||||
|
||||
const POTUS_ICAOS = new Set(['adfdf8', 'adfdf9', 'adfdfa', 'adfdfb', 'adfdfc', 'adfdff']);
|
||||
let dynamicData: DynamicMapLayersDataPayload = {
|
||||
commConfig: {} as FlightLayerConfig,
|
||||
privConfig: {} as FlightLayerConfig,
|
||||
jetsConfig: {} as FlightLayerConfig,
|
||||
milConfig: {} as FlightLayerConfig,
|
||||
};
|
||||
|
||||
function inView(lat: number, lng: number, bounds: BoundsTuple): boolean {
|
||||
return lng >= bounds[0] && lng <= bounds[2] && lat >= bounds[1] && lat <= bounds[3];
|
||||
}
|
||||
|
||||
function interpFlightPosition(f: Flight, dtSeconds: number): [number, number] {
|
||||
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
|
||||
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
|
||||
if (dtSeconds < 1) return [f.lng, f.lat];
|
||||
const heading = f.true_track || f.heading || 0;
|
||||
const [newLat, newLng] = interpolatePosition(
|
||||
f.lat,
|
||||
f.lng,
|
||||
heading,
|
||||
f.speed_knots,
|
||||
dtSeconds,
|
||||
0,
|
||||
UNBOUNDED_INTERP_SECONDS,
|
||||
);
|
||||
return [newLng, newLat];
|
||||
}
|
||||
|
||||
function interpShipPosition(s: Ship, dtSeconds: number): [number, number] {
|
||||
if (typeof s.sog !== 'number' || !s.sog || s.sog <= 0 || dtSeconds <= 0) return [s.lng, s.lat];
|
||||
const heading = (typeof s.cog === 'number' ? s.cog : 0) || s.heading || 0;
|
||||
const [newLat, newLng] = interpolatePosition(
|
||||
s.lat,
|
||||
s.lng,
|
||||
heading,
|
||||
s.sog,
|
||||
dtSeconds,
|
||||
0,
|
||||
UNBOUNDED_INTERP_SECONDS,
|
||||
);
|
||||
return [newLng, newLat];
|
||||
}
|
||||
|
||||
function buildFlightLayerGeoJSONWorker(
|
||||
flights: Flight[] | undefined,
|
||||
config: FlightLayerConfig,
|
||||
bounds: BoundsTuple,
|
||||
dtSeconds: number,
|
||||
trackedIcaos: Set<string>,
|
||||
): FC {
|
||||
if (!flights?.length) return null;
|
||||
const { colorMap, groundedMap, typeLabel, idPrefix, milSpecialMap, useTrackHeading } = config;
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
|
||||
for (let i = 0; i < flights.length; i += 1) {
|
||||
const f = flights[i];
|
||||
if (f.lat == null || f.lng == null) continue;
|
||||
const [iLng, iLat] = interpFlightPosition(f, dtSeconds);
|
||||
if (!inView(iLat, iLng, bounds)) continue;
|
||||
if (f.icao24 && trackedIcaos.has(f.icao24.toLowerCase())) continue;
|
||||
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
|
||||
let iconId: string;
|
||||
if (milSpecialMap) {
|
||||
const milType = ('military_type' in f ? f.military_type : undefined) || 'default';
|
||||
iconId = milSpecialMap[milType] || '';
|
||||
if (!iconId) {
|
||||
iconId = grounded ? groundedMap[acType] : colorMap[acType];
|
||||
} else if (grounded) {
|
||||
iconId = groundedMap[acType];
|
||||
}
|
||||
} else {
|
||||
iconId = grounded ? groundedMap[acType] : colorMap[acType];
|
||||
}
|
||||
|
||||
const rotation = useTrackHeading ? f.true_track || f.heading || 0 : f.heading || 0;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
id: f.icao24 || f.callsign || `${idPrefix}${i}`,
|
||||
type: typeLabel,
|
||||
callsign: f.callsign || f.icao24,
|
||||
rotation,
|
||||
iconId,
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [iLng, iLat] },
|
||||
});
|
||||
}
|
||||
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
function buildTrackedFlightsGeoJSONWorker(
|
||||
flights: Flight[] | undefined,
|
||||
bounds: BoundsTuple,
|
||||
dtSeconds: number,
|
||||
): FC {
|
||||
if (!flights?.length) return null;
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
|
||||
for (let i = 0; i < flights.length; i += 1) {
|
||||
const f = flights[i];
|
||||
if (f.lat == null || f.lng == null) continue;
|
||||
const [lng, lat] = interpFlightPosition(f, dtSeconds);
|
||||
if (!inView(lat, lng, bounds)) continue;
|
||||
|
||||
const alertColor = ('alert_color' in f ? f.alert_color : '') || 'white';
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
const icaoHex = (f.icao24 || '').toUpperCase();
|
||||
const isPotus = POTUS_ICAOS.has(icaoHex.toLowerCase());
|
||||
const potusIcon = acType === 'heli' ? 'svgPotusHeli' : 'svgPotusPlane';
|
||||
const iconId = isPotus
|
||||
? potusIcon
|
||||
: grounded
|
||||
? TRACKED_GROUNDED_ICON_MAP[acType]
|
||||
: TRACKED_ICON_MAP[acType]?.[alertColor] ||
|
||||
TRACKED_ICON_MAP.airliner[alertColor] ||
|
||||
'svgAirlinerWhite';
|
||||
const displayName =
|
||||
('alert_operator' in f ? f.alert_operator : '') ||
|
||||
('operator' in f ? f.operator : '') ||
|
||||
('owner' in f ? f.owner : '') ||
|
||||
('name' in f ? f.name : '') ||
|
||||
f.callsign ||
|
||||
f.icao24 ||
|
||||
'UNKNOWN';
|
||||
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
id: f.icao24 || i,
|
||||
type: 'tracked_flight',
|
||||
callsign: String(displayName),
|
||||
rotation: f.heading || 0,
|
||||
iconId,
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [lng, lat] },
|
||||
});
|
||||
}
|
||||
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
function buildShipsGeoJSONWorker(
|
||||
ships: Ship[] | undefined,
|
||||
activeLayers: DynamicMapLayersBuildPayload['activeLayers'],
|
||||
bounds: BoundsTuple,
|
||||
dtSeconds: number,
|
||||
): FC {
|
||||
if (
|
||||
!ships?.length ||
|
||||
!(
|
||||
activeLayers.ships_military ||
|
||||
activeLayers.ships_cargo ||
|
||||
activeLayers.ships_civilian ||
|
||||
activeLayers.ships_passenger ||
|
||||
activeLayers.ships_tracked_yachts
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (let i = 0; i < ships.length; i += 1) {
|
||||
const s = ships[i];
|
||||
if (s.lat == null || s.lng == null) continue;
|
||||
const [iLng, iLat] = interpShipPosition(s, dtSeconds);
|
||||
if (!inView(iLat, iLng, bounds)) continue;
|
||||
if (s.type === 'carrier') continue;
|
||||
|
||||
const isTrackedYacht = Boolean(s.yacht_alert);
|
||||
const isMilitary = s.type === 'military_vessel';
|
||||
const isCargo = s.type === 'tanker' || s.type === 'cargo';
|
||||
const isPassenger = s.type === 'passenger';
|
||||
|
||||
if (isTrackedYacht) {
|
||||
if (!activeLayers.ships_tracked_yachts) continue;
|
||||
} else if (isMilitary && !activeLayers.ships_military) continue;
|
||||
else if (isCargo && !activeLayers.ships_cargo) continue;
|
||||
else if (isPassenger && !activeLayers.ships_passenger) continue;
|
||||
else if (!isMilitary && !isCargo && !isPassenger && !activeLayers.ships_civilian) continue;
|
||||
|
||||
let iconId = 'svgShipBlue';
|
||||
if (isTrackedYacht) iconId = 'svgShipPink';
|
||||
else if (isCargo) iconId = 'svgShipRed';
|
||||
else if (s.type === 'yacht' || isPassenger) iconId = 'svgShipWhite';
|
||||
else if (isMilitary) iconId = 'svgShipAmber';
|
||||
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
id: s.mmsi || s.name || `ship-${i}`,
|
||||
type: 'ship',
|
||||
name: s.name,
|
||||
rotation: s.heading || 0,
|
||||
iconId,
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [iLng, iLat] },
|
||||
});
|
||||
}
|
||||
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
function buildSigintGeoJSONWorker(
|
||||
signals: SigintSignal[] | undefined,
|
||||
source: 'meshtastic' | 'aprs',
|
||||
bounds: BoundsTuple,
|
||||
): FC {
|
||||
if (!signals?.length) return null;
|
||||
const wanted =
|
||||
source === 'meshtastic'
|
||||
? (s: SigintSignal) => s.source === 'meshtastic'
|
||||
: (s: SigintSignal) => s.source === 'aprs' || s.source === 'js8call';
|
||||
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (let i = 0; i < signals.length; i += 1) {
|
||||
const sig = signals[i];
|
||||
if (!wanted(sig) || sig.lat == null || sig.lng == null) continue;
|
||||
if (!inView(sig.lat, sig.lng, bounds)) continue;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
id: `${sig.source || 'unknown'}:${sig.callsign || 'unknown'}`,
|
||||
type: 'sigint',
|
||||
name: sig.callsign,
|
||||
callsign: sig.callsign,
|
||||
source: sig.source,
|
||||
confidence: sig.confidence,
|
||||
raw_message: sig.raw_message || '',
|
||||
snr: sig.snr ?? null,
|
||||
frequency: sig.frequency ?? null,
|
||||
timestamp: sig.timestamp,
|
||||
region: sig.region ?? null,
|
||||
channel: sig.channel ?? null,
|
||||
status: sig.status ?? null,
|
||||
altitude: sig.altitude ?? null,
|
||||
emergency: sig.emergency ?? false,
|
||||
emergency_keyword: sig.emergency_keyword ?? null,
|
||||
from_api: sig.from_api ?? false,
|
||||
position_updated_at: sig.position_updated_at ?? null,
|
||||
long_name: sig.long_name ?? null,
|
||||
hardware: sig.hardware ?? null,
|
||||
role: sig.role ?? null,
|
||||
battery_level: sig.battery_level ?? null,
|
||||
voltage: sig.voltage ?? null,
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [sig.lng, sig.lat] },
|
||||
});
|
||||
}
|
||||
|
||||
return features.length ? { type: 'FeatureCollection', features } : null;
|
||||
}
|
||||
|
||||
/** Apply user-selected filters to flight/ship arrays before building GeoJSON. */
|
||||
function applyFilters(activeFilters: Record<string, string[]> | undefined) {
|
||||
const f = activeFilters;
|
||||
if (!f || Object.keys(f).length === 0) {
|
||||
return {
|
||||
commercial: dynamicData.commercialFlights,
|
||||
private_: dynamicData.privateFlights,
|
||||
jets: dynamicData.privateJets,
|
||||
military: dynamicData.militaryFlights,
|
||||
tracked: dynamicData.trackedFlights,
|
||||
ships: dynamicData.ships,
|
||||
};
|
||||
}
|
||||
|
||||
const has = (key: string) => f[key] && f[key].length > 0;
|
||||
const set = (key: string) => new Set(f[key]);
|
||||
|
||||
// ── Commercial flights ──
|
||||
let commercial = dynamicData.commercialFlights;
|
||||
if (commercial && (has('commercial_departure') || has('commercial_arrival') || has('commercial_airline'))) {
|
||||
const depSet = has('commercial_departure') ? set('commercial_departure') : null;
|
||||
const arrSet = has('commercial_arrival') ? set('commercial_arrival') : null;
|
||||
const airSet = has('commercial_airline') ? set('commercial_airline') : null;
|
||||
commercial = commercial.filter((fl: any) => {
|
||||
if (depSet && !depSet.has(fl.origin_name)) return false;
|
||||
if (arrSet && !arrSet.has(fl.dest_name)) return false;
|
||||
if (airSet && !airSet.has(fl.airline_code)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Private flights ──
|
||||
let private_ = dynamicData.privateFlights;
|
||||
if (private_ && (has('private_callsign') || has('private_aircraft_type'))) {
|
||||
const csSet = has('private_callsign') ? set('private_callsign') : null;
|
||||
const typeSet = has('private_aircraft_type') ? set('private_aircraft_type') : null;
|
||||
private_ = private_.filter((fl: any) => {
|
||||
if (csSet && !csSet.has(fl.callsign) && !csSet.has(fl.registration)) return false;
|
||||
if (typeSet && !typeSet.has(fl.model)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Private jets ──
|
||||
let jets = dynamicData.privateJets;
|
||||
if (jets && (has('private_callsign') || has('private_aircraft_type'))) {
|
||||
const csSet = has('private_callsign') ? set('private_callsign') : null;
|
||||
const typeSet = has('private_aircraft_type') ? set('private_aircraft_type') : null;
|
||||
jets = jets.filter((fl: any) => {
|
||||
if (csSet && !csSet.has(fl.callsign) && !csSet.has(fl.registration)) return false;
|
||||
if (typeSet && !typeSet.has(fl.model)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Military flights ──
|
||||
let military = dynamicData.militaryFlights;
|
||||
if (military && (has('military_country') || has('military_aircraft_type'))) {
|
||||
const countrySet = has('military_country') ? set('military_country') : null;
|
||||
const typeSet = has('military_aircraft_type') ? set('military_aircraft_type') : null;
|
||||
military = military.filter((fl: any) => {
|
||||
if (countrySet && !countrySet.has(fl.country)) return false;
|
||||
if (typeSet && !typeSet.has(fl.military_type)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tracked flights ──
|
||||
let tracked = dynamicData.trackedFlights;
|
||||
if (tracked && (has('tracked_category') || has('tracked_owner'))) {
|
||||
const catSet = has('tracked_category') ? set('tracked_category') : null;
|
||||
const ownSet = has('tracked_owner') ? set('tracked_owner') : null;
|
||||
tracked = tracked.filter((fl: any) => {
|
||||
if (catSet && !catSet.has(fl.alert_category)) return false;
|
||||
if (ownSet && !ownSet.has(fl.alert_operator)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Ships ──
|
||||
let ships = dynamicData.ships;
|
||||
if (ships && (has('ship_name') || has('ship_type'))) {
|
||||
const nameSet = has('ship_name') ? set('ship_name') : null;
|
||||
const typeSet = has('ship_type') ? set('ship_type') : null;
|
||||
ships = ships.filter((s: any) => {
|
||||
if (nameSet && !nameSet.has(s.name)) return false;
|
||||
if (typeSet && !typeSet.has(s.type)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return { commercial, private_, jets, military, tracked, ships };
|
||||
}
|
||||
|
||||
function buildDynamicLayers(payload: DynamicMapLayersBuildPayload): DynamicMapLayersResult {
|
||||
const trackedIcaos = new Set(payload.trackedIcaos);
|
||||
const filtered = applyFilters(payload.activeFilters);
|
||||
return {
|
||||
commercialFlightsGeoJSON: payload.activeLayers.flights
|
||||
? buildFlightLayerGeoJSONWorker(
|
||||
filtered.commercial,
|
||||
dynamicData.commConfig,
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
trackedIcaos,
|
||||
)
|
||||
: null,
|
||||
privateFlightsGeoJSON: payload.activeLayers.private
|
||||
? buildFlightLayerGeoJSONWorker(
|
||||
filtered.private_,
|
||||
dynamicData.privConfig,
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
trackedIcaos,
|
||||
)
|
||||
: null,
|
||||
privateJetsGeoJSON: payload.activeLayers.jets
|
||||
? buildFlightLayerGeoJSONWorker(
|
||||
filtered.jets,
|
||||
dynamicData.jetsConfig,
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
trackedIcaos,
|
||||
)
|
||||
: null,
|
||||
militaryFlightsGeoJSON: payload.activeLayers.military
|
||||
? buildFlightLayerGeoJSONWorker(
|
||||
filtered.military,
|
||||
dynamicData.milConfig,
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
trackedIcaos,
|
||||
)
|
||||
: null,
|
||||
trackedFlightsGeoJSON: payload.activeLayers.tracked
|
||||
? buildTrackedFlightsGeoJSONWorker(filtered.tracked, payload.bounds, payload.dtSeconds)
|
||||
: null,
|
||||
shipsGeoJSON: buildShipsGeoJSONWorker(
|
||||
filtered.ships,
|
||||
payload.activeLayers,
|
||||
payload.bounds,
|
||||
payload.dtSeconds,
|
||||
),
|
||||
meshtasticGeoJSON: payload.activeLayers.sigint_meshtastic
|
||||
? buildSigintGeoJSONWorker(dynamicData.sigint, 'meshtastic', payload.bounds)
|
||||
: null,
|
||||
aprsGeoJSON: payload.activeLayers.sigint_aprs
|
||||
? buildSigintGeoJSONWorker(dynamicData.sigint, 'aprs', payload.bounds)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
const { id, action, payload } = event.data;
|
||||
try {
|
||||
if (action === 'sync_dynamic_layers') {
|
||||
dynamicData = payload;
|
||||
postMessage({ id, ok: true, result: EMPTY_RESULT } satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
if (action !== 'build_dynamic_layers') {
|
||||
postMessage({ id, ok: false, error: 'unsupported_action' } satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
const result = buildDynamicLayers(payload);
|
||||
postMessage({ id, ok: true, result } satisfies WorkerResponse);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'worker_error';
|
||||
postMessage({ id, ok: false, error: message } satisfies WorkerResponse);
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
@@ -2,22 +2,42 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildEarthquakesGeoJSON,
|
||||
buildFirmsGeoJSON,
|
||||
buildInternetOutagesGeoJSON,
|
||||
buildDataCentersGeoJSON,
|
||||
buildShipsGeoJSON,
|
||||
buildCarriersGeoJSON,
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type { Earthquake, FireHotspot, InternetOutage, DataCenter, Ship, ActiveLayers } from '@/types/dashboard';
|
||||
import type {
|
||||
Earthquake,
|
||||
FireHotspot,
|
||||
Ship,
|
||||
ActiveLayers,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
// Default active layers for ship tests
|
||||
const allShipLayers: ActiveLayers = {
|
||||
flights: true, private: true, jets: true, military: true, tracked: true,
|
||||
satellites: true, earthquakes: true, cctv: false, ukraine_frontline: true,
|
||||
global_incidents: true, firms_fires: true, jamming: true, internet_outages: true,
|
||||
datacenters: true, gdelt: false, liveuamap: true, weather: true, uav: true,
|
||||
flights: true,
|
||||
private: true,
|
||||
jets: true,
|
||||
military: true,
|
||||
tracked: true,
|
||||
satellites: true,
|
||||
earthquakes: true,
|
||||
cctv: false,
|
||||
ukraine_frontline: true,
|
||||
global_incidents: true,
|
||||
firms_fires: true,
|
||||
jamming: true,
|
||||
internet_outages: true,
|
||||
datacenters: true,
|
||||
gdelt: false,
|
||||
liveuamap: true,
|
||||
weather: true,
|
||||
uav: true,
|
||||
kiwisdr: false,
|
||||
ships_military: true, ships_cargo: true, ships_civilian: true,
|
||||
ships_passenger: true, ships_tracked_yachts: true,
|
||||
ships_military: true,
|
||||
ships_cargo: true,
|
||||
ships_civilian: true,
|
||||
ships_passenger: true,
|
||||
ships_tracked_yachts: true,
|
||||
};
|
||||
|
||||
describe('buildEarthquakesGeoJSON', () => {
|
||||
@@ -59,10 +79,46 @@ describe('buildFirmsGeoJSON', () => {
|
||||
|
||||
it('assigns correct icon by FRP intensity', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 2, brightness: 300, confidence: 'high', daynight: 'D', acq_date: '2025-01-01', acq_time: '1200' }, // yellow
|
||||
{ lat: 10, lng: 21, frp: 10, brightness: 350, confidence: 'high', daynight: 'D', acq_date: '2025-01-01', acq_time: '1200' }, // orange
|
||||
{ lat: 10, lng: 22, frp: 50, brightness: 400, confidence: 'high', daynight: 'N', acq_date: '2025-01-01', acq_time: '0000' }, // red
|
||||
{ lat: 10, lng: 23, frp: 200, brightness: 500, confidence: 'high', daynight: 'N', acq_date: '2025-01-01', acq_time: '0000' }, // darkred
|
||||
{
|
||||
lat: 10,
|
||||
lng: 20,
|
||||
frp: 2,
|
||||
brightness: 300,
|
||||
confidence: 'high',
|
||||
daynight: 'D',
|
||||
acq_date: '2025-01-01',
|
||||
acq_time: '1200',
|
||||
}, // yellow
|
||||
{
|
||||
lat: 10,
|
||||
lng: 21,
|
||||
frp: 10,
|
||||
brightness: 350,
|
||||
confidence: 'high',
|
||||
daynight: 'D',
|
||||
acq_date: '2025-01-01',
|
||||
acq_time: '1200',
|
||||
}, // orange
|
||||
{
|
||||
lat: 10,
|
||||
lng: 22,
|
||||
frp: 50,
|
||||
brightness: 400,
|
||||
confidence: 'high',
|
||||
daynight: 'N',
|
||||
acq_date: '2025-01-01',
|
||||
acq_time: '0000',
|
||||
}, // red
|
||||
{
|
||||
lat: 10,
|
||||
lng: 23,
|
||||
frp: 200,
|
||||
brightness: 500,
|
||||
confidence: 'high',
|
||||
daynight: 'N',
|
||||
acq_date: '2025-01-01',
|
||||
acq_time: '0000',
|
||||
}, // darkred
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires)!;
|
||||
expect(result.features[0].properties?.iconId).toBe('fire-yellow');
|
||||
@@ -77,7 +133,14 @@ describe('buildShipsGeoJSON', () => {
|
||||
const interpIdentity = (s: Ship): [number, number] => [s.lng!, s.lat!];
|
||||
|
||||
it('returns null when all ship layers are off', () => {
|
||||
const layers = { ...allShipLayers, ships_military: false, ships_cargo: false, ships_civilian: false, ships_passenger: false, ships_tracked_yachts: false };
|
||||
const layers = {
|
||||
...allShipLayers,
|
||||
ships_military: false,
|
||||
ships_cargo: false,
|
||||
ships_civilian: false,
|
||||
ships_passenger: false,
|
||||
ships_tracked_yachts: false,
|
||||
};
|
||||
const ships: Ship[] = [{ name: 'Test', lat: 10, lng: 20, type: 'cargo' } as Ship];
|
||||
expect(buildShipsGeoJSON(ships, layers, alwaysInView, interpIdentity)).toBeNull();
|
||||
});
|
||||
@@ -101,7 +164,7 @@ describe('buildShipsGeoJSON', () => {
|
||||
const result = buildShipsGeoJSON(ships, allShipLayers, alwaysInView, interpIdentity)!;
|
||||
expect(result.features[0].properties?.iconId).toBe('svgShipRed');
|
||||
expect(result.features[1].properties?.iconId).toBe('svgShipWhite');
|
||||
expect(result.features[2].properties?.iconId).toBe('svgShipYellow');
|
||||
expect(result.features[2].properties?.iconId).toBe('svgShipAmber');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,77 +1,113 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import type { MapGeoJSONFeature } from 'maplibre-gl';
|
||||
|
||||
export interface ClusterItem {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: string | number;
|
||||
id: number;
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: string | number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts cluster label positions from a MapLibre clustered source.
|
||||
* Listens for moveend/sourcedata events to keep labels in sync.
|
||||
* Queries only rendered cluster features for a given cluster layer to avoid
|
||||
* scanning the full clustered source on every update.
|
||||
*
|
||||
* @param mapRef - React ref to the MapLibre map instance
|
||||
* @param sourceId - The source ID to query clusters from (e.g. "ships", "earthquakes")
|
||||
* @param layerId - The rendered cluster layer ID to query (e.g. "ships-clusters-layer")
|
||||
* @param geoJSON - The GeoJSON data driving the source (null = no clusters)
|
||||
*/
|
||||
export function useClusterLabels(
|
||||
mapRef: React.RefObject<MapRef | null>,
|
||||
sourceId: string,
|
||||
geoJSON: unknown | null
|
||||
mapRef: React.RefObject<MapRef | null>,
|
||||
layerId: string,
|
||||
geoJSON: unknown | null,
|
||||
): ClusterItem[] {
|
||||
const [clusters, setClusters] = useState<ClusterItem[]>([]);
|
||||
const handlerRef = useRef<(() => void) | null>(null);
|
||||
const [clusters, setClusters] = useState<ClusterItem[]>([]);
|
||||
const handlerRef = useRef<(() => void) | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const signatureRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current?.getMap();
|
||||
if (!map || !geoJSON) {
|
||||
setClusters([]);
|
||||
return;
|
||||
useEffect(() => {
|
||||
const map = mapRef.current?.getMap();
|
||||
if (!map || !geoJSON) {
|
||||
setClusters([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove previous handler if it exists
|
||||
if (handlerRef.current) {
|
||||
map.off('moveend', handlerRef.current);
|
||||
map.off('idle', handlerRef.current);
|
||||
}
|
||||
|
||||
const runUpdate = () => {
|
||||
try {
|
||||
if (!map.getLayer(layerId)) {
|
||||
setClusters([]);
|
||||
signatureRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove previous handler if it exists
|
||||
if (handlerRef.current) {
|
||||
map.off("moveend", handlerRef.current);
|
||||
map.off("sourcedata", handlerRef.current);
|
||||
const features = map.queryRenderedFeatures(undefined, {
|
||||
layers: [layerId],
|
||||
}) as MapGeoJSONFeature[];
|
||||
const raw = features
|
||||
.filter((f) => f.properties?.cluster)
|
||||
.map((f) => {
|
||||
const point = f.geometry as GeoJSON.Point;
|
||||
return {
|
||||
lng: point.coordinates[0],
|
||||
lat: point.coordinates[1],
|
||||
count: f.properties?.point_count_abbreviated ?? f.properties?.point_count ?? 0,
|
||||
id: Number(f.properties?.cluster_id ?? 0),
|
||||
};
|
||||
});
|
||||
const seen = new Set<number>();
|
||||
const unique = raw.filter((c) => {
|
||||
if (seen.has(c.id)) return false;
|
||||
seen.add(c.id);
|
||||
return true;
|
||||
});
|
||||
const signature = unique
|
||||
.map((c) => `${c.id}:${c.count}:${c.lng.toFixed(3)}:${c.lat.toFixed(3)}`)
|
||||
.join('|');
|
||||
if (signature !== signatureRef.current) {
|
||||
signatureRef.current = signature;
|
||||
setClusters(unique);
|
||||
}
|
||||
} catch {
|
||||
if (signatureRef.current !== '') {
|
||||
signatureRef.current = '';
|
||||
setClusters([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
const scheduleUpdate = () => {
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
runUpdate();
|
||||
});
|
||||
};
|
||||
handlerRef.current = scheduleUpdate;
|
||||
|
||||
const update = () => {
|
||||
try {
|
||||
const features = map.querySourceFeatures(sourceId);
|
||||
const raw = features
|
||||
.filter((f: any) => f.properties?.cluster)
|
||||
.map((f: any) => ({
|
||||
lng: (f.geometry as any).coordinates[0],
|
||||
lat: (f.geometry as any).coordinates[1],
|
||||
count: f.properties.point_count_abbreviated || f.properties.point_count,
|
||||
id: f.properties.cluster_id,
|
||||
}));
|
||||
const seen = new Set<number>();
|
||||
const unique = raw.filter((c) => {
|
||||
if (seen.has(c.id)) return false;
|
||||
seen.add(c.id);
|
||||
return true;
|
||||
});
|
||||
setClusters(unique);
|
||||
} catch {
|
||||
setClusters([]);
|
||||
}
|
||||
};
|
||||
handlerRef.current = update;
|
||||
map.on('moveend', scheduleUpdate);
|
||||
map.on('idle', scheduleUpdate);
|
||||
scheduleUpdate();
|
||||
|
||||
map.on("moveend", update);
|
||||
map.on("sourcedata", update);
|
||||
setTimeout(update, 500);
|
||||
return () => {
|
||||
map.off('moveend', scheduleUpdate);
|
||||
map.off('idle', scheduleUpdate);
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [geoJSON, layerId, mapRef]);
|
||||
|
||||
return () => {
|
||||
map.off("moveend", update);
|
||||
map.off("sourcedata", update);
|
||||
};
|
||||
}, [geoJSON, sourceId]);
|
||||
|
||||
return clusters;
|
||||
return clusters;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { DependencyList } from 'react';
|
||||
import type {
|
||||
DynamicMapLayersBuildPayload,
|
||||
DynamicMapLayersDataPayload,
|
||||
DynamicMapLayersResult,
|
||||
} from '@/components/map/dynamicMapLayers.worker';
|
||||
|
||||
type SyncRequest = {
|
||||
id: string;
|
||||
action: 'sync_dynamic_layers';
|
||||
payload: DynamicMapLayersDataPayload;
|
||||
};
|
||||
|
||||
type BuildRequest = {
|
||||
id: string;
|
||||
action: 'build_dynamic_layers';
|
||||
payload: DynamicMapLayersBuildPayload;
|
||||
};
|
||||
|
||||
type WorkerRequest = SyncRequest | BuildRequest;
|
||||
|
||||
type WorkerResponse = {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: DynamicMapLayersResult;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const EMPTY_RESULT: DynamicMapLayersResult = {
|
||||
commercialFlightsGeoJSON: null,
|
||||
privateFlightsGeoJSON: null,
|
||||
privateJetsGeoJSON: null,
|
||||
militaryFlightsGeoJSON: null,
|
||||
trackedFlightsGeoJSON: null,
|
||||
shipsGeoJSON: null,
|
||||
meshtasticGeoJSON: null,
|
||||
aprsGeoJSON: null,
|
||||
};
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let reqCounter = 0;
|
||||
const pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: DynamicMapLayersResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
function ensureWorker(): Worker {
|
||||
if (worker) return worker;
|
||||
worker = new Worker(new URL('../dynamicMapLayers.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const msg = event.data;
|
||||
const handler = pending.get(msg.id);
|
||||
if (!handler) return;
|
||||
pending.delete(msg.id);
|
||||
if (msg.ok && msg.result) {
|
||||
handler.resolve(msg.result);
|
||||
} else {
|
||||
handler.reject(new Error(msg.error || 'worker_error'));
|
||||
}
|
||||
};
|
||||
return worker;
|
||||
}
|
||||
|
||||
function callWorker(request: WorkerRequest): Promise<DynamicMapLayersResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(request.id, { resolve, reject });
|
||||
try {
|
||||
ensureWorker().postMessage(request);
|
||||
} catch (error) {
|
||||
pending.delete(request.id);
|
||||
reject(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDynamicMapLayersWorker(
|
||||
dataPayload: DynamicMapLayersDataPayload,
|
||||
dataDeps: DependencyList,
|
||||
buildPayload: DynamicMapLayersBuildPayload,
|
||||
buildDeps: DependencyList,
|
||||
): DynamicMapLayersResult {
|
||||
const [result, setResult] = useState<DynamicMapLayersResult>(EMPTY_RESULT);
|
||||
const [syncVersion, setSyncVersion] = useState(0);
|
||||
const syncVersionRef = useRef(0);
|
||||
const requestVersionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const id = `mapw_sync_${Date.now()}_${reqCounter++}`;
|
||||
const currentSyncVersion = ++syncVersionRef.current;
|
||||
|
||||
callWorker({ id, action: 'sync_dynamic_layers', payload: dataPayload })
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setSyncVersion(currentSyncVersion);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Dynamic map layer worker sync failed', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, dataDeps);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const requestVersion = ++requestVersionRef.current;
|
||||
const id = `mapw_build_${Date.now()}_${reqCounter++}`;
|
||||
|
||||
callWorker({ id, action: 'build_dynamic_layers', payload: buildPayload })
|
||||
.then((next) => {
|
||||
if (!cancelled && requestVersion === requestVersionRef.current) {
|
||||
setResult(next);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Dynamic map layer worker build failed', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [syncVersion, ...buildDeps]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,25 +1,81 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
import { EMPTY_FC } from "@/components/map/mapConstants";
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { EMPTY_FC } from '@/components/map/mapConstants';
|
||||
|
||||
// Imperatively push GeoJSON data to a MapLibre source, bypassing React reconciliation.
|
||||
// This is critical for high-volume layers (flights, ships, satellites, fires) where
|
||||
// React's prop diffing on thousands of coordinate arrays causes memory pressure.
|
||||
export function useImperativeSource(map: MapRef | null, sourceId: string, geojson: any, debounceMs = 0) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const push = () => {
|
||||
const src = map.getSource(sourceId) as any;
|
||||
if (src && typeof src.setData === 'function') {
|
||||
src.setData(geojson || EMPTY_FC);
|
||||
}
|
||||
};
|
||||
if (debounceMs > 0) {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(push, debounceMs);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}
|
||||
push();
|
||||
}, [map, sourceId, geojson, debounceMs]);
|
||||
export function useImperativeSource(
|
||||
map: MapRef | null,
|
||||
sourceId: string,
|
||||
geojson: GeoJSON.FeatureCollection | null,
|
||||
debounceMs = 0,
|
||||
) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
let cancelled = false;
|
||||
const data = geojson || EMPTY_FC;
|
||||
const rawMap = map.getMap();
|
||||
|
||||
const push = () => {
|
||||
if (cancelled) return true;
|
||||
const src = rawMap.getSource(sourceId) as GeoJSONSource | undefined;
|
||||
if (src && typeof src.setData === 'function') {
|
||||
src.setData(data);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const pushWhenReady = () => {
|
||||
let attemptsRemaining = 20;
|
||||
|
||||
const tryPush = () => {
|
||||
if (cancelled) return;
|
||||
if (push()) return;
|
||||
if (attemptsRemaining <= 0) return;
|
||||
attemptsRemaining -= 1;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = setTimeout(tryPush, 100);
|
||||
};
|
||||
|
||||
tryPush();
|
||||
};
|
||||
|
||||
const schedulePush = () => {
|
||||
if (cancelled) return;
|
||||
if (debounceMs > 0) {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(pushWhenReady, debounceMs);
|
||||
return;
|
||||
}
|
||||
pushWhenReady();
|
||||
};
|
||||
|
||||
const handleStyleData = () => {
|
||||
pushWhenReady();
|
||||
};
|
||||
|
||||
rawMap.on('styledata', handleStyleData);
|
||||
|
||||
// Skip redundant writes for unchanged references, but keep the styledata
|
||||
// listener active so sources repopulate after style reloads.
|
||||
if (geojson !== prevRef.current) {
|
||||
prevRef.current = geojson;
|
||||
schedulePush();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
rawMap.off('styledata', handleStyleData);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
};
|
||||
}, [map, sourceId, geojson, debounceMs]);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,123 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||
import { interpolatePosition } from "@/utils/positioning";
|
||||
import { INTERP_TICK_MS } from "@/lib/constants";
|
||||
import { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import { interpolatePosition } from '@/utils/positioning';
|
||||
import { INTERP_TICK_MS } from '@/lib/constants';
|
||||
|
||||
const UNBOUNDED_INTERP_SECONDS = Number.POSITIVE_INFINITY;
|
||||
|
||||
/**
|
||||
* Custom hook that provides position interpolation for flights, ships, and satellites.
|
||||
* Tracks elapsed time since last data refresh and provides helper functions
|
||||
* to smoothly animate entity positions between API updates.
|
||||
*
|
||||
* The interp functions read dtSeconds from a ref so their references stay stable.
|
||||
* This prevents 7 GeoJSON useMemos from re-firing every tick — GeoJSON only rebuilds
|
||||
* when source data actually changes (new API fetch), not on every interpolation tick.
|
||||
*/
|
||||
export function useInterpolation() {
|
||||
// Interpolation tick — bumps every INTERP_TICK_MS to animate entity positions
|
||||
const [interpTick, setInterpTick] = useState(0);
|
||||
const dataTimestamp = useRef(Date.now());
|
||||
const dataTimestamp = useRef(Date.now());
|
||||
const dtRef = useRef(0);
|
||||
const [interpTick, setInterpTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setInterpTick((t) => t + 1), INTERP_TICK_MS);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
// Update dtSeconds on each tick and bump a lightweight counter so moving
|
||||
// layers actually rebuild between backend refreshes.
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
dtRef.current = (Date.now() - dataTimestamp.current) / 1000;
|
||||
setInterpTick((tick) => tick + 1);
|
||||
}, INTERP_TICK_MS);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
/** Call this when new data arrives to reset the interpolation baseline */
|
||||
const resetTimestamp = useCallback(() => {
|
||||
dataTimestamp.current = Date.now();
|
||||
}, []);
|
||||
/** Call this when new data arrives to reset the interpolation baseline */
|
||||
const resetTimestamp = useCallback(() => {
|
||||
dataTimestamp.current = Date.now();
|
||||
dtRef.current = 0;
|
||||
}, []);
|
||||
|
||||
// Elapsed seconds since last data refresh (used for position interpolation)
|
||||
const dtSeconds = useMemo(() => {
|
||||
void interpTick; // use the tick to trigger recalc
|
||||
return (Date.now() - dataTimestamp.current) / 1000;
|
||||
}, [interpTick]);
|
||||
/** Interpolate a flight's position if airborne and has speed + heading */
|
||||
const interpFlight = useCallback(
|
||||
(f: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
speed_knots?: number | null;
|
||||
alt?: number | null;
|
||||
true_track?: number;
|
||||
heading?: number;
|
||||
}): [number, number] => {
|
||||
const dt = dtRef.current;
|
||||
if (!f.speed_knots || f.speed_knots <= 0 || dt <= 0) return [f.lng, f.lat];
|
||||
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
|
||||
if (dt < 1) return [f.lng, f.lat];
|
||||
const heading = f.true_track || f.heading || 0;
|
||||
const [newLat, newLng] = interpolatePosition(
|
||||
f.lat,
|
||||
f.lng,
|
||||
heading,
|
||||
f.speed_knots,
|
||||
dt,
|
||||
0,
|
||||
UNBOUNDED_INTERP_SECONDS,
|
||||
);
|
||||
return [newLng, newLat];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Interpolate a flight's position if airborne and has speed + heading */
|
||||
const interpFlight = useCallback(
|
||||
(f: { lat: number; lng: number; speed_knots?: number | null; alt?: number | null; true_track?: number; heading?: number }): [number, number] => {
|
||||
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
|
||||
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
|
||||
if (dtSeconds < 1) return [f.lng, f.lat];
|
||||
const heading = f.true_track || f.heading || 0;
|
||||
const [newLat, newLng] = interpolatePosition(f.lat, f.lng, heading, f.speed_knots, dtSeconds);
|
||||
return [newLng, newLat];
|
||||
},
|
||||
[dtSeconds]
|
||||
);
|
||||
/** Interpolate a ship's position using SOG + COG */
|
||||
const interpShip = useCallback(
|
||||
(s: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
sog?: number;
|
||||
cog?: number;
|
||||
heading?: number;
|
||||
}): [number, number] => {
|
||||
const dt = dtRef.current;
|
||||
if (typeof s.sog !== 'number' || !s.sog || s.sog <= 0 || dt <= 0)
|
||||
return [s.lng, s.lat];
|
||||
const heading = (typeof s.cog === 'number' ? s.cog : 0) || s.heading || 0;
|
||||
const [newLat, newLng] = interpolatePosition(
|
||||
s.lat,
|
||||
s.lng,
|
||||
heading,
|
||||
s.sog,
|
||||
dt,
|
||||
0,
|
||||
UNBOUNDED_INTERP_SECONDS,
|
||||
);
|
||||
return [newLng, newLat];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Interpolate a ship's position using SOG + COG */
|
||||
const interpShip = useCallback(
|
||||
(s: { lat: number; lng: number; sog?: number; cog?: number; heading?: number }): [number, number] => {
|
||||
if (typeof s.sog !== "number" || !s.sog || s.sog <= 0 || dtSeconds <= 0) return [s.lng, s.lat];
|
||||
const heading = (typeof s.cog === "number" ? s.cog : 0) || s.heading || 0;
|
||||
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, heading, s.sog, dtSeconds);
|
||||
return [newLng, newLat];
|
||||
},
|
||||
[dtSeconds]
|
||||
);
|
||||
/** Interpolate a satellite's position between API updates */
|
||||
const interpSat = useCallback(
|
||||
(s: { lat: number; lng: number; speed_knots?: number; heading?: number }): [number, number] => {
|
||||
const dt = dtRef.current;
|
||||
if (!s.speed_knots || s.speed_knots <= 0 || dt < 1) return [s.lng, s.lat];
|
||||
const [newLat, newLng] = interpolatePosition(
|
||||
s.lat,
|
||||
s.lng,
|
||||
s.heading || 0,
|
||||
s.speed_knots,
|
||||
dt,
|
||||
0,
|
||||
UNBOUNDED_INTERP_SECONDS,
|
||||
);
|
||||
return [newLng, newLat];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Interpolate a satellite's position between API updates */
|
||||
const interpSat = useCallback(
|
||||
(s: { lat: number; lng: number; speed_knots?: number; heading?: number }): [number, number] => {
|
||||
if (!s.speed_knots || s.speed_knots <= 0 || dtSeconds < 1) return [s.lng, s.lat];
|
||||
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, s.heading || 0, s.speed_knots, dtSeconds, 0, 65);
|
||||
return [newLng, newLat];
|
||||
},
|
||||
[dtSeconds]
|
||||
);
|
||||
|
||||
return { interpTick, interpFlight, interpShip, interpSat, dtSeconds, resetTimestamp, dataTimestamp };
|
||||
return {
|
||||
interpFlight,
|
||||
interpShip,
|
||||
interpSat,
|
||||
interpTick,
|
||||
dtSeconds: dtRef,
|
||||
resetTimestamp,
|
||||
dataTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { DependencyList } from 'react';
|
||||
import type {
|
||||
StaticMapLayersBuildPayload,
|
||||
StaticMapLayersDataPayload,
|
||||
StaticMapLayersResult,
|
||||
} from '@/components/map/staticMapLayers.worker';
|
||||
|
||||
type SyncRequest = {
|
||||
id: string;
|
||||
action: 'sync_static_layers';
|
||||
payload: StaticMapLayersDataPayload;
|
||||
};
|
||||
|
||||
type BuildRequest = {
|
||||
id: string;
|
||||
action: 'build_static_layers';
|
||||
payload: StaticMapLayersBuildPayload;
|
||||
};
|
||||
|
||||
type WorkerRequest = SyncRequest | BuildRequest;
|
||||
|
||||
type WorkerResponse = {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: StaticMapLayersResult | true;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const EMPTY_RESULT: StaticMapLayersResult = {
|
||||
cctvGeoJSON: null,
|
||||
kiwisdrGeoJSON: null,
|
||||
pskReporterGeoJSON: null,
|
||||
satnogsGeoJSON: null,
|
||||
scannerGeoJSON: null,
|
||||
firmsGeoJSON: null,
|
||||
internetOutagesGeoJSON: null,
|
||||
dataCentersGeoJSON: null,
|
||||
powerPlantsGeoJSON: null,
|
||||
viirsChangeNodesGeoJSON: null,
|
||||
militaryBasesGeoJSON: null,
|
||||
gdeltGeoJSON: null,
|
||||
liveuaGeoJSON: null,
|
||||
airQualityGeoJSON: null,
|
||||
volcanoesGeoJSON: null,
|
||||
fishingGeoJSON: null,
|
||||
trainsGeoJSON: null,
|
||||
};
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let reqCounter = 0;
|
||||
const pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: StaticMapLayersResult | true) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
function ensureWorker(): Worker {
|
||||
if (worker) return worker;
|
||||
worker = new Worker(new URL('../staticMapLayers.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const msg = event.data;
|
||||
const handler = pending.get(msg.id);
|
||||
if (!handler) return;
|
||||
pending.delete(msg.id);
|
||||
if (msg.ok && msg.result !== undefined) {
|
||||
handler.resolve(msg.result);
|
||||
} else {
|
||||
handler.reject(new Error(msg.error || 'worker_error'));
|
||||
}
|
||||
};
|
||||
return worker;
|
||||
}
|
||||
|
||||
function callWorker(request: WorkerRequest): Promise<StaticMapLayersResult | true> {
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(request.id, { resolve, reject });
|
||||
try {
|
||||
ensureWorker().postMessage(request);
|
||||
} catch (error) {
|
||||
pending.delete(request.id);
|
||||
reject(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function nextRequestId(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${reqCounter++}`;
|
||||
}
|
||||
|
||||
export function useStaticMapLayersWorker(
|
||||
dataPayload: StaticMapLayersDataPayload,
|
||||
dataDeps: DependencyList,
|
||||
buildPayload: StaticMapLayersBuildPayload,
|
||||
buildDeps: DependencyList,
|
||||
): StaticMapLayersResult {
|
||||
const [result, setResult] = useState<StaticMapLayersResult>(EMPTY_RESULT);
|
||||
const [syncVersion, setSyncVersion] = useState(0);
|
||||
const syncVersionRef = useRef(0);
|
||||
const buildRequestVersionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const requestId = nextRequestId('mapsync');
|
||||
const currentSyncVersion = ++syncVersionRef.current;
|
||||
|
||||
callWorker({ id: requestId, action: 'sync_static_layers', payload: dataPayload })
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setSyncVersion(currentSyncVersion);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Static map layer worker sync failed', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, dataDeps);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const requestVersion = ++buildRequestVersionRef.current;
|
||||
const requestId = nextRequestId('mapbuild');
|
||||
|
||||
callWorker({ id: requestId, action: 'build_static_layers', payload: buildPayload })
|
||||
.then((next) => {
|
||||
if (
|
||||
!cancelled &&
|
||||
requestVersion === buildRequestVersionRef.current &&
|
||||
next !== true
|
||||
) {
|
||||
setResult(next);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Static map layer worker build failed', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [syncVersion, ...buildDeps]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import {
|
||||
coarsenViewBounds,
|
||||
expandBoundsToRadius,
|
||||
normalizeViewBounds,
|
||||
type ViewBounds,
|
||||
} from '@/lib/viewportPrivacy';
|
||||
|
||||
const VIEWPORT_POST_DEBOUNCE_MS = 2500;
|
||||
const VIEWPORT_POST_MIN_INTERVAL_MS = 12000;
|
||||
const VIEWPORT_CHANGE_EPSILON = 1.5;
|
||||
export const VIEWPORT_COMMITTED_EVENT = 'shadowbroker:viewport-committed';
|
||||
|
||||
function boundsChanged(a: ViewBounds | null, b: ViewBounds): boolean {
|
||||
if (!a) return true;
|
||||
return (
|
||||
Math.abs(a.south - b.south) > VIEWPORT_CHANGE_EPSILON ||
|
||||
Math.abs(a.west - b.west) > VIEWPORT_CHANGE_EPSILON ||
|
||||
Math.abs(a.north - b.north) > VIEWPORT_CHANGE_EPSILON ||
|
||||
Math.abs(a.east - b.east) > VIEWPORT_CHANGE_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function useViewportBounds(
|
||||
mapRef: RefObject<MapRef | null>,
|
||||
viewBoundsRef?: { current: ViewBounds | null },
|
||||
backendViewportSyncEnabled: boolean = true,
|
||||
) {
|
||||
// Viewport bounds for culling off-screen features [west, south, east, north]
|
||||
const [mapBounds, setMapBounds] = useState<[number, number, number, number]>([
|
||||
-180, -90, 180, 90,
|
||||
]);
|
||||
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastPostedBoundsRef = useRef<ViewBounds | null>(null);
|
||||
const lastPostedAtRef = useRef(0);
|
||||
const lastCommittedBoundsRef = useRef<ViewBounds | null>(null);
|
||||
|
||||
const updateBounds = useCallback(() => {
|
||||
const map = mapRef.current?.getMap();
|
||||
if (!map) return;
|
||||
const b = map.getBounds();
|
||||
const latRange = b.getNorth() - b.getSouth();
|
||||
const lngRange = b.getEast() - b.getWest();
|
||||
const buf = 0.2; // 20% buffer
|
||||
setMapBounds([
|
||||
b.getWest() - lngRange * buf,
|
||||
b.getSouth() - latRange * buf,
|
||||
b.getEast() + lngRange * buf,
|
||||
b.getNorth() + latRange * buf,
|
||||
]);
|
||||
|
||||
const normalized = normalizeViewBounds({
|
||||
south: b.getSouth(),
|
||||
west: b.getWest(),
|
||||
north: b.getNorth(),
|
||||
east: b.getEast(),
|
||||
});
|
||||
const preloadBounds = coarsenViewBounds(expandBoundsToRadius(normalized));
|
||||
|
||||
if (viewBoundsRef && 'current' in viewBoundsRef) {
|
||||
viewBoundsRef.current = preloadBounds;
|
||||
}
|
||||
|
||||
if (boundsChanged(lastCommittedBoundsRef.current, preloadBounds)) {
|
||||
lastCommittedBoundsRef.current = preloadBounds;
|
||||
window.dispatchEvent(new CustomEvent(VIEWPORT_COMMITTED_EVENT));
|
||||
}
|
||||
|
||||
// Debounce POSTing viewport bounds to backend for dynamic AIS stream filtering
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (!backendViewportSyncEnabled) {
|
||||
lastPostedBoundsRef.current = null;
|
||||
lastPostedAtRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (
|
||||
!boundsChanged(lastPostedBoundsRef.current, preloadBounds) &&
|
||||
now - lastPostedAtRef.current < VIEWPORT_POST_MIN_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (now - lastPostedAtRef.current < VIEWPORT_POST_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastPostedBoundsRef.current = preloadBounds;
|
||||
lastPostedAtRef.current = now;
|
||||
fetch(`${API_BASE}/api/viewport`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
s: preloadBounds.south,
|
||||
w: preloadBounds.west,
|
||||
n: preloadBounds.north,
|
||||
e: preloadBounds.east,
|
||||
}),
|
||||
}).catch((e) => console.error('Failed to update backend viewport:', e));
|
||||
}, VIEWPORT_POST_DEBOUNCE_MS);
|
||||
}, [backendViewportSyncEnabled, mapRef, viewBoundsRef]);
|
||||
|
||||
const inView = useCallback(
|
||||
(lat: number, lng: number) =>
|
||||
lng >= mapBounds[0] && lng <= mapBounds[2] && lat >= mapBounds[1] && lat <= mapBounds[3],
|
||||
[mapBounds],
|
||||
);
|
||||
|
||||
const scheduleBoundsUpdate = useCallback(() => {
|
||||
updateBounds();
|
||||
}, [updateBounds]);
|
||||
|
||||
return { mapBounds, inView, updateBounds, scheduleBoundsUpdate };
|
||||
}
|
||||
@@ -8,8 +8,11 @@ export const svgPlanePurple = `data:image/svg+xml;utf8,${encodeURIComponent(`<sv
|
||||
export const svgFighter = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="yellow" stroke="black"><path d="M12 2L14 8L18 10L14 16L15 22L12 20L9 22L10 16L6 10L10 8L12 2Z"/></svg>`)}`;
|
||||
export const svgHeli = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="yellow" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="black" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
export const svgHeliCyan = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="cyan" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="cyan" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
export const svgHeliDimCyan = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#0891b2" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#0891b2" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
export const svgHeliOrange = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#FF8C00" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#FF8C00" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
export const svgHeliPurple = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#9B59B6" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#9B59B6" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
export const svgHeliSlate = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#94a3b8" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#94a3b8" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
export const svgHeliAmber = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#f59e0b" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#f59e0b" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
export const svgTanker = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="yellow" stroke="black"><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" /><line x1="12" y1="20" x2="12" y2="24" stroke="yellow" stroke-width="2" /></svg>`)}`;
|
||||
export const svgRecon = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="yellow" stroke="black"><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" /><ellipse cx="12" cy="11" rx="5" ry="3" fill="none" stroke="red" stroke-width="1.5"/></svg>`)}`;
|
||||
export const svgPlanePink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#FF1493" stroke="black"><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>`)}`;
|
||||
@@ -33,37 +36,47 @@ export const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg
|
||||
export const svgShipBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#3b82f6" stroke="#000" stroke-width="1"/></svg>`)}`;
|
||||
export const svgShipWhite = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="36" viewBox="0 0 24 24" fill="none"><path d="M5 21 L5 8 L12 2 L19 8 L19 21 C19 23 5 23 5 21 Z" fill="white" stroke="#000" stroke-width="1"/><rect x="7" y="10" width="10" height="8" fill="#90cdf4" stroke="#000" stroke-width="1"/><circle cx="12" cy="14" r="2" fill="yellow" stroke="#000"/></svg>`)}`;
|
||||
export const svgShipPink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="36" viewBox="0 0 24 24" fill="none"><path d="M5 21 L5 8 L12 2 L19 8 L19 21 C19 23 5 23 5 21 Z" fill="#FF69B4" stroke="#000" stroke-width="1"/><rect x="7" y="10" width="10" height="8" fill="#ff8dc7" stroke="#000" stroke-width="1"/><circle cx="12" cy="14" r="2" fill="white" stroke="#000"/></svg>`)}`;
|
||||
export const svgShipGreyBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#64748b" stroke="#000" stroke-width="1"/><rect x="8" y="15" width="8" height="4" fill="#475569" stroke="#000" stroke-width="1"/><rect x="8" y="7" width="8" height="6" fill="#444" stroke="#000" stroke-width="1"/></svg>`)}`;
|
||||
export const svgShipAmber = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="34" viewBox="0 0 24 24" fill="none"><path d="M7 22 L7 6 L12 1 L17 6 L17 22 Z" fill="#f59e0b" stroke="#000" stroke-width="1"/><rect x="9" y="8" width="6" height="8" fill="#555" stroke="#000" stroke-width="1"/><circle cx="12" cy="18" r="1.5" fill="#000"/><line x1="12" y1="18" x2="12" y2="24" stroke="#000" stroke-width="1.5"/></svg>`)}`;
|
||||
export const svgCarrier = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="orange" stroke="black"><polygon points="3,21 21,21 20,4 16,4 16,3 12,3 12,4 4,4" /><rect x="15" y="6" width="3" height="10" /></svg>`)}`;
|
||||
export const svgCctv = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="cyan" stroke-width="2"><path d="M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-.894.553H5.652a1 1 0 0 1-.894-.553L2.724 13.447A1 1 0 0 1 3.618 12h3.632M14 12V8a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a4 4 0 1 0 8 0Z" /></svg>`)}`;
|
||||
export const svgRadioTower = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="1.5"><line x1="12" y1="10" x2="12" y2="23" stroke="#f59e0b" stroke-width="2"/><line x1="8" y1="23" x2="16" y2="23" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/><line x1="9" y1="16" x2="15" y2="16" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="9" r="2" fill="#f59e0b" stroke="none"/><path d="M8 6a5.5 5.5 0 0 1 8 0" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round"/><path d="M5.5 3.5a9 9 0 0 1 13 0" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/></svg>`)}`;
|
||||
export const svgScannerTower = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="1.5"><line x1="12" y1="10" x2="12" y2="23" stroke="#dc2626" stroke-width="2"/><line x1="8" y1="23" x2="16" y2="23" stroke="#dc2626" stroke-width="2" stroke-linecap="round"/><line x1="9" y1="16" x2="15" y2="16" stroke="#dc2626" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="9" r="2" fill="#dc2626" stroke="none"/><path d="M8 6a5.5 5.5 0 0 1 8 0" fill="none" stroke="#dc2626" stroke-width="1.5" stroke-linecap="round"/><path d="M5.5 3.5a9 9 0 0 1 13 0" fill="none" stroke="#dc2626" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/></svg>`)}`;
|
||||
export const svgSatDish = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#14b8a6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10a7.31 7.31 0 0 0 10 10Z"/><path d="m9 15 3-3"/><path d="M17 13a6 6 0 0 0-6-6"/><path d="M21 13A10 10 0 0 0 11 3"/><circle cx="12" cy="12" r="1.5" fill="#14b8a6" stroke="none"/></svg>`)}`;
|
||||
export const svgLoRaSat = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#a855f7" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="8" height="8" rx="1" fill="#a855f7" fill-opacity="0.3"/><line x1="4" y1="12" x2="8" y2="12"/><line x1="16" y1="12" x2="20" y2="12"/><line x1="12" y1="4" x2="12" y2="8"/><line x1="12" y1="16" x2="12" y2="20"/><circle cx="12" cy="12" r="2" fill="#a855f7" stroke="none"/></svg>`)}`;
|
||||
export const svgWarning = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="yellow" stroke="black"><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>`)}`;
|
||||
export const svgThreat = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" 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>`)}`;
|
||||
export const svgTriangleYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#ffaa00" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`)}`;
|
||||
export const svgTriangleRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#ff0000" stroke="#fff" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`)}`;
|
||||
export const svgTrianglePink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#f472b6" stroke="#1a1a2e" stroke-width="1.5"><path d="M1 21h22L12 2 1 21z"/></svg>`)}`;
|
||||
export const svgTriangleGreen = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#22c55e" stroke="#1a1a2e" stroke-width="1.5"><path d="M1 21h22L12 2 1 21z"/></svg>`)}`;
|
||||
|
||||
// --- Aircraft type-specific SVG paths (top-down silhouettes) ---
|
||||
// Airliner: wide swept wings with engine pods, narrow fuselage
|
||||
export const AIRLINER_PATH = "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 M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5";
|
||||
export const AIRLINER_PATH =
|
||||
'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 M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5';
|
||||
// Turboprop: straight high wings, shorter body
|
||||
export const TURBOPROP_PATH = "M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z";
|
||||
export const TURBOPROP_PATH =
|
||||
'M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z';
|
||||
// Bizjet: sleek, small swept wings, T-tail
|
||||
export const BIZJET_PATH = "M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z";
|
||||
export const BIZJET_PATH =
|
||||
'M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z';
|
||||
|
||||
// --- Fire icon SVGs for FIRMS hotspots (multi-tongue flame, unmistakably fire) ---
|
||||
export function makeFireSvg(fill: string, innerFill: string, size = 18) {
|
||||
// Multi-forked flame: main body + left tongue + right tongue + inner glow
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 28">` +
|
||||
// Main flame body (wide base, pointed top)
|
||||
`<path d="M12 1C12 1 9 5 8 8C7 11 5.5 13 5.5 16.5C5.5 20.5 8 23.5 12 23.5C16 23.5 18.5 20.5 18.5 16.5C18.5 13 17 11 16 8C15 5 12 1 12 1Z" fill="${fill}" stroke="rgba(0,0,0,0.7)" stroke-width="0.7"/>` +
|
||||
// Left tongue (forks out left from top)
|
||||
`<path d="M10 8C10 8 7.5 4.5 7 2.5C7 2.5 6 5.5 7 9C7.5 10.5 8.5 11.5 9.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
|
||||
// Right tongue (forks out right from top)
|
||||
`<path d="M14 8C14 8 16.5 4.5 17 2.5C17 2.5 18 5.5 17 9C16.5 10.5 15.5 11.5 14.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
|
||||
// Inner bright core
|
||||
`<path d="M12 8C12 8 10.5 11 10.5 14.5C10.5 17.5 11 19.5 12 20C13 19.5 13.5 17.5 13.5 14.5C13.5 11 12 8 12 8Z" fill="${innerFill}" opacity="0.85"/>` +
|
||||
`</svg>`
|
||||
)}`;
|
||||
// Multi-forked flame: main body + left tongue + right tongue + inner glow
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 28">` +
|
||||
// Main flame body (wide base, pointed top)
|
||||
`<path d="M12 1C12 1 9 5 8 8C7 11 5.5 13 5.5 16.5C5.5 20.5 8 23.5 12 23.5C16 23.5 18.5 20.5 18.5 16.5C18.5 13 17 11 16 8C15 5 12 1 12 1Z" fill="${fill}" stroke="rgba(0,0,0,0.7)" stroke-width="0.7"/>` +
|
||||
// Left tongue (forks out left from top)
|
||||
`<path d="M10 8C10 8 7.5 4.5 7 2.5C7 2.5 6 5.5 7 9C7.5 10.5 8.5 11.5 9.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
|
||||
// Right tongue (forks out right from top)
|
||||
`<path d="M14 8C14 8 16.5 4.5 17 2.5C17 2.5 18 5.5 17 9C16.5 10.5 15.5 11.5 14.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
|
||||
// Inner bright core
|
||||
`<path d="M12 8C12 8 10.5 11 10.5 14.5C10.5 17.5 11 19.5 12 20C13 19.5 13.5 17.5 13.5 14.5C13.5 11 12 8 12 8Z" fill="${innerFill}" opacity="0.85"/>` +
|
||||
`</svg>`,
|
||||
)}`;
|
||||
}
|
||||
export const svgFireYellow = makeFireSvg('#ffcc00', '#fff5aa', 16);
|
||||
export const svgFireOrange = makeFireSvg('#ff8800', '#ffcc00', 18);
|
||||
@@ -75,12 +88,26 @@ export const svgFireClusterMed = makeFireSvg('#ff3300', '#ff8800', 40);
|
||||
export const svgFireClusterLarge = makeFireSvg('#cc0000', '#ff3300', 48);
|
||||
export const svgFireClusterXL = makeFireSvg('#880000', '#cc0000', 56);
|
||||
|
||||
export function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic', fill: string, stroke = 'black', size = 20) {
|
||||
const paths: Record<string, string> = { airliner: AIRLINER_PATH, turboprop: TURBOPROP_PATH, bizjet: BIZJET_PATH, generic: "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" };
|
||||
const p = paths[type] || paths.generic;
|
||||
// Airliner gets engine pod circles
|
||||
const extras = type === 'airliner' ? `<circle cx="7" cy="12.5" r="1.2" fill="${fill}" stroke="${stroke}" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="${fill}" stroke="${stroke}" stroke-width="0.5"/>` : '';
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="${stroke}"><path d="${p}"/>${extras}</svg>`)}`;
|
||||
export function makeAircraftSvg(
|
||||
type: 'airliner' | 'turboprop' | 'bizjet' | 'generic',
|
||||
fill: string,
|
||||
stroke = 'black',
|
||||
size = 20,
|
||||
) {
|
||||
const paths: Record<string, string> = {
|
||||
airliner: AIRLINER_PATH,
|
||||
turboprop: TURBOPROP_PATH,
|
||||
bizjet: BIZJET_PATH,
|
||||
generic:
|
||||
'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',
|
||||
};
|
||||
const p = paths[type] || paths.generic;
|
||||
// Airliner gets engine pod circles
|
||||
const extras =
|
||||
type === 'airliner'
|
||||
? `<circle cx="7" cy="12.5" r="1.2" fill="${fill}" stroke="${stroke}" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="${fill}" stroke="${stroke}" stroke-width="0.5"/>`
|
||||
: '';
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="${stroke}"><path d="${p}"/>${extras}</svg>`)}`;
|
||||
}
|
||||
|
||||
// POTUS fleet — oversized hot pink with yellow halo ring
|
||||
@@ -89,17 +116,30 @@ export const svgPotusHeli = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg
|
||||
|
||||
// POTUS fleet ICAO hex codes (verified FAA registry)
|
||||
export const POTUS_ICAOS = new Set([
|
||||
'ADFDF8','ADFDF9', // Air Force One (VC-25A)
|
||||
'ADFEB7','ADFEB8','ADFEB9','ADFEBA', // Air Force Two (C-32A)
|
||||
'AE4AE6','AE4AE8','AE4AEA','AE4AEC', // Air Force Two (C-32B)
|
||||
'AE0865','AE5E76','AE5E77','AE5E79', // Marine One (VH-3D / VH-92A)
|
||||
'ADFDF8',
|
||||
'ADFDF9', // Air Force One (VC-25A)
|
||||
'ADFEB7',
|
||||
'ADFEB8',
|
||||
'ADFEB9',
|
||||
'ADFEBA', // Air Force Two (C-32A)
|
||||
'AE4AE6',
|
||||
'AE4AE8',
|
||||
'AE4AEA',
|
||||
'AE4AEC', // Air Force Two (C-32B)
|
||||
'AE0865',
|
||||
'AE5E76',
|
||||
'AE5E77',
|
||||
'AE5E79', // Marine One (VH-3D / VH-92A)
|
||||
]);
|
||||
|
||||
// Pre-built aircraft SVGs by type & color
|
||||
export const svgAirlinerCyan = makeAircraftSvg('airliner', 'cyan');
|
||||
export const svgAirlinerDimCyan = makeAircraftSvg('airliner', '#0891b2');
|
||||
export const svgAirlinerOrange = makeAircraftSvg('airliner', '#FF8C00');
|
||||
export const svgAirlinerPurple = makeAircraftSvg('airliner', '#9B59B6');
|
||||
export const svgAirlinerSlate = makeAircraftSvg('airliner', '#94a3b8');
|
||||
export const svgAirlinerYellow = makeAircraftSvg('airliner', 'yellow');
|
||||
export const svgAirlinerAmber = makeAircraftSvg('airliner', '#f59e0b');
|
||||
export const svgAirlinerPink = makeAircraftSvg('airliner', '#FF1493', 'black', 22);
|
||||
export const svgAirlinerRed = makeAircraftSvg('airliner', '#FF2020', 'black', 22);
|
||||
export const svgAirlinerDarkBlue = makeAircraftSvg('airliner', '#1A3A8A', '#4A80D0', 22);
|
||||
@@ -109,9 +149,12 @@ export const svgAirlinerBlack = makeAircraftSvg('airliner', '#222', '#555', 22);
|
||||
export const svgAirlinerWhite = makeAircraftSvg('airliner', 'white', '#666', 22);
|
||||
|
||||
export const svgTurbopropCyan = makeAircraftSvg('turboprop', 'cyan');
|
||||
export const svgTurbopropDimCyan = makeAircraftSvg('turboprop', '#0891b2');
|
||||
export const svgTurbopropOrange = makeAircraftSvg('turboprop', '#FF8C00');
|
||||
export const svgTurbopropPurple = makeAircraftSvg('turboprop', '#9B59B6');
|
||||
export const svgTurbopropSlate = makeAircraftSvg('turboprop', '#94a3b8');
|
||||
export const svgTurbopropYellow = makeAircraftSvg('turboprop', 'yellow');
|
||||
export const svgTurbopropAmber = makeAircraftSvg('turboprop', '#f59e0b');
|
||||
export const svgTurbopropPink = makeAircraftSvg('turboprop', '#FF1493', 'black', 22);
|
||||
export const svgTurbopropRed = makeAircraftSvg('turboprop', '#FF2020', 'black', 22);
|
||||
export const svgTurbopropDarkBlue = makeAircraftSvg('turboprop', '#1A3A8A', '#4A80D0', 22);
|
||||
@@ -121,9 +164,12 @@ export const svgTurbopropBlack = makeAircraftSvg('turboprop', '#222', '#555', 22
|
||||
export const svgTurbopropWhite = makeAircraftSvg('turboprop', 'white', '#666', 22);
|
||||
|
||||
export const svgBizjetCyan = makeAircraftSvg('bizjet', 'cyan');
|
||||
export const svgBizjetDimCyan = makeAircraftSvg('bizjet', '#0891b2');
|
||||
export const svgBizjetOrange = makeAircraftSvg('bizjet', '#FF8C00');
|
||||
export const svgBizjetPurple = makeAircraftSvg('bizjet', '#9B59B6');
|
||||
export const svgBizjetSlate = makeAircraftSvg('bizjet', '#94a3b8');
|
||||
export const svgBizjetYellow = makeAircraftSvg('bizjet', 'yellow');
|
||||
export const svgBizjetAmber = makeAircraftSvg('bizjet', '#f59e0b');
|
||||
export const svgBizjetPink = makeAircraftSvg('bizjet', '#FF1493', 'black', 22);
|
||||
export const svgBizjetRed = makeAircraftSvg('bizjet', '#FF2020', 'black', 22);
|
||||
export const svgBizjetDarkBlue = makeAircraftSvg('bizjet', '#1A3A8A', '#4A80D0', 22);
|
||||
@@ -139,11 +185,356 @@ export const svgBizjetGrey = makeAircraftSvg('bizjet', '#555', '#333');
|
||||
export const svgHeliGrey = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#555" stroke="#333"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#555" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
|
||||
// Grey icon map for grounded aircraft
|
||||
export const GROUNDED_ICON_MAP: Record<string, string> = { heli: 'svgHeliGrey', turboprop: 'svgTurbopropGrey', bizjet: 'svgBizjetGrey', airliner: 'svgAirlinerGrey' };
|
||||
export const GROUNDED_ICON_MAP: Record<string, string> = {
|
||||
heli: 'svgHeliGrey',
|
||||
turboprop: 'svgTurbopropGrey',
|
||||
bizjet: 'svgBizjetGrey',
|
||||
airliner: 'svgAirlinerGrey',
|
||||
};
|
||||
|
||||
// Per-layer color maps (module-level to avoid re-allocation every render tick)
|
||||
export const COLOR_MAP_COMMERCIAL: Record<string, string> = { heli: 'svgHeliCyan', turboprop: 'svgTurbopropCyan', bizjet: 'svgBizjetCyan', airliner: 'svgAirlinerCyan' };
|
||||
export const COLOR_MAP_PRIVATE: Record<string, string> = { heli: 'svgHeliOrange', turboprop: 'svgTurbopropOrange', bizjet: 'svgBizjetOrange', airliner: 'svgAirlinerOrange' };
|
||||
export const COLOR_MAP_JETS: Record<string, string> = { heli: 'svgHeliPurple', turboprop: 'svgTurbopropPurple', bizjet: 'svgBizjetPurple', airliner: 'svgAirlinerPurple' };
|
||||
export const COLOR_MAP_MILITARY: Record<string, string> = { heli: 'svgHeli', turboprop: 'svgTurbopropYellow', bizjet: 'svgBizjetYellow', airliner: 'svgAirlinerYellow' };
|
||||
export const MIL_SPECIAL_MAP: Record<string, string> = { fighter: 'svgFighter', tanker: 'svgTanker', recon: 'svgRecon' };
|
||||
// 3-tier hierarchy: Baseline (dim cyan/slate) → Watch (amber) → Critical (red/gold/pink)
|
||||
export const COLOR_MAP_COMMERCIAL: Record<string, string> = {
|
||||
heli: 'svgHeliDimCyan',
|
||||
turboprop: 'svgTurbopropDimCyan',
|
||||
bizjet: 'svgBizjetDimCyan',
|
||||
airliner: 'svgAirlinerDimCyan',
|
||||
};
|
||||
export const COLOR_MAP_PRIVATE: Record<string, string> = {
|
||||
heli: 'svgHeliPurple',
|
||||
turboprop: 'svgTurbopropPurple',
|
||||
bizjet: 'svgBizjetPurple',
|
||||
airliner: 'svgAirlinerPurple',
|
||||
};
|
||||
export const COLOR_MAP_JETS: Record<string, string> = {
|
||||
heli: 'svgHeliPurple',
|
||||
turboprop: 'svgTurbopropPurple',
|
||||
bizjet: 'svgBizjetPurple',
|
||||
airliner: 'svgAirlinerPurple',
|
||||
};
|
||||
export const COLOR_MAP_MILITARY: Record<string, string> = {
|
||||
heli: 'svgHeliAmber',
|
||||
turboprop: 'svgTurbopropAmber',
|
||||
bizjet: 'svgBizjetAmber',
|
||||
airliner: 'svgAirlinerAmber',
|
||||
};
|
||||
export const MIL_SPECIAL_MAP: Record<string, string> = {
|
||||
fighter: 'svgFighter',
|
||||
tanker: 'svgTanker',
|
||||
recon: 'svgRecon',
|
||||
};
|
||||
|
||||
// ─── Military Base Icons (square with X or circle) ───────────────────────
|
||||
|
||||
/** Generate a square-with-X SVG data URL for military base markers. */
|
||||
export function makeMilBaseSvg(fill: string, xColor: string): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">` +
|
||||
`<rect x="1" y="1" width="18" height="18" fill="${fill}" stroke="#000" stroke-width="1.5"/>` +
|
||||
`<line x1="1" y1="1" x2="19" y2="19" stroke="${xColor}" stroke-width="2.5"/>` +
|
||||
`<line x1="19" y1="1" x2="1" y2="19" stroke="${xColor}" stroke-width="2.5"/>` +
|
||||
`</svg>`
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** Generate a square-with-filled-circle SVG data URL for military base markers. */
|
||||
export function makeMilBaseCircleSvg(fill: string, circleColor: string): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">` +
|
||||
`<rect x="1" y="1" width="18" height="18" fill="${fill}" stroke="#000" stroke-width="1.5"/>` +
|
||||
`<circle cx="10" cy="10" r="5.5" fill="${circleColor}"/>` +
|
||||
`</svg>`
|
||||
)}`;
|
||||
}
|
||||
|
||||
function makeMilBaseCustomSvg(content: string): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">` +
|
||||
`<rect x="1" y="1" width="18" height="18" rx="1.5" fill="#111827" stroke="#000" stroke-width="1.5"/>` +
|
||||
content +
|
||||
`</svg>`
|
||||
)}`;
|
||||
}
|
||||
|
||||
function makeMilBaseUSSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="18" fill="#ffffff"/>` +
|
||||
`<rect x="1" y="1" width="18" height="2.6" fill="#b91c1c"/>` +
|
||||
`<rect x="1" y="6.2" width="18" height="2.6" fill="#b91c1c"/>` +
|
||||
`<rect x="1" y="11.4" width="18" height="2.6" fill="#b91c1c"/>` +
|
||||
`<rect x="1" y="16.6" width="18" height="2.4" fill="#b91c1c"/>` +
|
||||
`<rect x="1" y="1" width="8.5" height="8.5" fill="#1d4ed8"/>` +
|
||||
`<circle cx="5.2" cy="5.2" r="1.6" fill="#ffffff"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseChinaSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="18" fill="#dc2626"/>` +
|
||||
`<polygon points="6,3.2 6.8,5.2 9,5.3 7.2,6.7 7.9,8.8 6,7.5 4.1,8.8 4.8,6.7 3,5.3 5.2,5.2" fill="#facc15"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseUnionJackSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="18" fill="#1d4ed8"/>` +
|
||||
`<rect x="8" y="1" width="4" height="18" fill="#ffffff"/>` +
|
||||
`<rect x="1" y="8" width="18" height="4" fill="#ffffff"/>` +
|
||||
`<rect x="8.8" y="1" width="2.4" height="18" fill="#dc2626"/>` +
|
||||
`<rect x="1" y="8.8" width="18" height="2.4" fill="#dc2626"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseRussiaSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="6" fill="#ffffff"/>` +
|
||||
`<rect x="1" y="7" width="18" height="6" fill="#2563eb"/>` +
|
||||
`<rect x="1" y="13" width="18" height="6" fill="#dc2626"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseIndiaSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="6" fill="#f97316"/>` +
|
||||
`<rect x="1" y="7" width="18" height="6" fill="#ffffff"/>` +
|
||||
`<rect x="1" y="13" width="18" height="6" fill="#16a34a"/>` +
|
||||
`<circle cx="10" cy="10" r="2.4" fill="none" stroke="#1d4ed8" stroke-width="1.2"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseNorthKoreaSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="3" fill="#1d4ed8"/>` +
|
||||
`<rect x="1" y="4" width="18" height="1.2" fill="#ffffff"/>` +
|
||||
`<rect x="1" y="5.2" width="18" height="9.6" fill="#dc2626"/>` +
|
||||
`<rect x="1" y="14.8" width="18" height="1.2" fill="#ffffff"/>` +
|
||||
`<rect x="1" y="16" width="18" height="3" fill="#1d4ed8"/>` +
|
||||
`<circle cx="6.2" cy="10" r="2.5" fill="#ffffff"/>` +
|
||||
`<polygon points="6.2,7.8 6.8,9.3 8.4,9.4 7.1,10.4 7.6,11.9 6.2,11 4.8,11.9 5.3,10.4 4,9.4 5.6,9.3" fill="#dc2626"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseIsraelSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="18" fill="#ffffff"/>` +
|
||||
`<rect x="1" y="2.2" width="18" height="2.2" fill="#2563eb"/>` +
|
||||
`<rect x="1" y="15.6" width="18" height="2.2" fill="#2563eb"/>` +
|
||||
`<line x1="5" y1="5" x2="15" y2="15" stroke="#2563eb" stroke-width="1.8"/>` +
|
||||
`<line x1="15" y1="5" x2="5" y2="15" stroke="#2563eb" stroke-width="1.8"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBasePakistanSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="4" height="18" fill="#ffffff"/>` +
|
||||
`<rect x="5" y="1" width="14" height="18" fill="#166534"/>` +
|
||||
`<circle cx="12" cy="10" r="3.2" fill="#ffffff"/>` +
|
||||
`<circle cx="13.2" cy="10" r="2.7" fill="#166534"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseTaiwanSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="18" fill="#dc2626"/>` +
|
||||
`<rect x="1" y="1" width="8.5" height="8.5" fill="#1d4ed8"/>` +
|
||||
`<circle cx="5.2" cy="5.2" r="1.8" fill="#ffffff"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBasePhilippinesSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="9" fill="#2563eb"/>` +
|
||||
`<rect x="1" y="10" width="18" height="9" fill="#dc2626"/>` +
|
||||
`<polygon points="1,1 1,19 9.5,10" fill="#ffffff"/>` +
|
||||
`<circle cx="4.6" cy="10" r="1.5" fill="#facc15"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseAustraliaSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="18" fill="#1e3a8a"/>` +
|
||||
`<circle cx="12.8" cy="12" r="2" fill="#ffffff"/>` +
|
||||
`<circle cx="6" cy="6" r="1.2" fill="#ffffff"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseSouthKoreaSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="18" fill="#ffffff"/>` +
|
||||
`<path d="M10 6 A4 4 0 0 1 14 10 H6 A4 4 0 0 1 10 6Z" fill="#ef4444"/>` +
|
||||
`<path d="M6 10 H14 A4 4 0 0 1 10 14 A4 4 0 0 1 6 10Z" fill="#2563eb"/>` +
|
||||
`<rect x="4" y="4.4" width="2.2" height="0.8" fill="#111827"/>` +
|
||||
`<rect x="13.8" y="14.8" width="2.2" height="0.8" fill="#111827"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function makeMilBaseIranSvg(): string {
|
||||
return makeMilBaseCustomSvg(
|
||||
`<rect x="1" y="1" width="18" height="6" fill="#16a34a"/>` +
|
||||
`<rect x="1" y="7" width="18" height="6" fill="#ffffff"/>` +
|
||||
`<rect x="1" y="13" width="18" height="6" fill="#dc2626"/>` +
|
||||
`<circle cx="10" cy="10" r="1.6" fill="none" stroke="#dc2626" stroke-width="1.1"/>`
|
||||
);
|
||||
}
|
||||
|
||||
/** All unique military base icon specs for preloading. */
|
||||
export const MILBASE_ICON_SPECS: {
|
||||
id: string;
|
||||
fill: string;
|
||||
inner: string;
|
||||
shape: 'x' | 'circle';
|
||||
svg?: string;
|
||||
}[] = [
|
||||
{ id: 'milbase-us', fill: '#1d4ed8', inner: '#ffffff', shape: 'x', svg: makeMilBaseUSSvg() }, // US — stripes + canton
|
||||
{ id: 'milbase-cn', fill: '#dc2626', inner: '#facc15', shape: 'x', svg: makeMilBaseChinaSvg() }, // China — red + yellow star
|
||||
{ id: 'milbase-uk', fill: '#1d4ed8', inner: '#ffffff', shape: 'x', svg: makeMilBaseUnionJackSvg() }, // UK — Union Jack-ish cross
|
||||
{ id: 'milbase-jp', fill: '#ffffff', inner: '#ef4444', shape: 'circle' }, // Japan — white / red circle
|
||||
{ id: 'milbase-ru', fill: '#2563eb', inner: '#dc2626', shape: 'x', svg: makeMilBaseRussiaSvg() }, // Russia — tricolor
|
||||
{ id: 'milbase-in', fill: '#f97316', inner: '#1d4ed8', shape: 'circle', svg: makeMilBaseIndiaSvg() }, // India — tricolor + chakra
|
||||
{ id: 'milbase-eu-x', fill: '#3b82f6', inner: '#fbbf24', shape: 'circle' }, // EU big 3 — blue / yellow circle
|
||||
{ id: 'milbase-nk', fill: '#dc2626', inner: '#1d4ed8', shape: 'circle', svg: makeMilBaseNorthKoreaSvg() }, // North Korea — red/blue flag
|
||||
{ id: 'milbase-il', fill: '#ffffff', inner: '#2563eb', shape: 'x', svg: makeMilBaseIsraelSvg() }, // Israel — white + blue bands
|
||||
{ id: 'milbase-pk', fill: '#166534', inner: '#ffffff', shape: 'circle', svg: makeMilBasePakistanSvg() }, // Pakistan — green + crescent
|
||||
{ id: 'milbase-tw', fill: '#dc2626', inner: '#ffffff', shape: 'circle', svg: makeMilBaseTaiwanSvg() }, // Taiwan — red + blue canton
|
||||
{ id: 'milbase-ph', fill: '#2563eb', inner: '#facc15', shape: 'circle', svg: makeMilBasePhilippinesSvg() }, // Philippines — blue/red + sun
|
||||
{ id: 'milbase-au', fill: '#1e3a8a', inner: '#ffffff', shape: 'circle', svg: makeMilBaseAustraliaSvg() }, // Australia — navy + stars
|
||||
{ id: 'milbase-sk', fill: '#ffffff', inner: '#2563eb', shape: 'circle', svg: makeMilBaseSouthKoreaSvg() }, // South Korea — white + taegeuk
|
||||
{ id: 'milbase-ir', fill: '#16a34a', inner: '#dc2626', shape: 'circle', svg: makeMilBaseIranSvg() }, // Iran — tricolor
|
||||
{ id: 'milbase-eu', fill: '#3b82f6', inner: '#fbbf24', shape: 'circle' }, // EU others — blue / yellow circle
|
||||
{ id: 'milbase-default', fill: '#ec4899', inner: '#000000', shape: 'x' }, // Unknown — pink / black X
|
||||
];
|
||||
|
||||
|
||||
/** Generate a volcano-cone SVG data URL for volcano markers. */
|
||||
export function makeVolcanoSvg(fill: string): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">` +
|
||||
`<polygon points="10,2 18,18 2,18" fill="${fill}" stroke="#000" stroke-width="1.2"/>` +
|
||||
`<line x1="8" y1="3" x2="6" y2="0" stroke="${fill}" stroke-width="1.5" stroke-linecap="round"/>` +
|
||||
`<line x1="10" y1="2" x2="10" y2="0" stroke="${fill}" stroke-width="1.5" stroke-linecap="round"/>` +
|
||||
`<line x1="12" y1="3" x2="14" y2="0" stroke="${fill}" stroke-width="1.5" stroke-linecap="round"/>` +
|
||||
`</svg>`
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** Volcano icon specs for preloading. */
|
||||
export const VOLCANO_ICON_SPECS: { id: string; fill: string }[] = [
|
||||
{ id: 'volcano-active', fill: '#ef4444' }, // Red — erupted within 50 years
|
||||
{ id: 'volcano-historical', fill: '#f97316' }, // Orange — erupted 50–500 years ago
|
||||
{ id: 'volcano-dormant', fill: '#6b7280' }, // Grey — dormant (500+ years)
|
||||
];
|
||||
|
||||
// ─── Weather Icons ─────────────────────────────────────────────────────────────
|
||||
|
||||
function weatherSvg(inner: string): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">${inner}</svg>`
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** Thunderstorm — cloud with lightning bolt */
|
||||
export const svgWeatherThunderstorm = weatherSvg(
|
||||
`<ellipse cx="16" cy="12" rx="10" ry="6" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
|
||||
`<ellipse cx="12" cy="11" rx="6" ry="5" fill="#94a3b8"/>` +
|
||||
`<polygon points="15,17 12,24 16,22 13,30 19,21 15,23" fill="#facc15" stroke="#a16207" stroke-width="0.5"/>`,
|
||||
);
|
||||
|
||||
/** Rain — cloud with rain drops */
|
||||
export const svgWeatherRain = weatherSvg(
|
||||
`<ellipse cx="16" cy="11" rx="10" ry="6" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
|
||||
`<ellipse cx="12" cy="10" rx="6" ry="5" fill="#94a3b8"/>` +
|
||||
`<line x1="10" y1="19" x2="8" y2="25" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>` +
|
||||
`<line x1="16" y1="19" x2="14" y2="25" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>` +
|
||||
`<line x1="22" y1="19" x2="20" y2="25" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>` +
|
||||
`<line x1="13" y1="22" x2="11" y2="28" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>` +
|
||||
`<line x1="19" y1="22" x2="17" y2="28" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>`,
|
||||
);
|
||||
|
||||
/** Snow / Winter — cloud with snowflakes */
|
||||
export const svgWeatherSnow = weatherSvg(
|
||||
`<ellipse cx="16" cy="11" rx="10" ry="6" fill="#94a3b8" stroke="#64748b" stroke-width="1"/>` +
|
||||
`<ellipse cx="12" cy="10" rx="6" ry="5" fill="#cbd5e1"/>` +
|
||||
`<circle cx="10" cy="21" r="1.5" fill="#e2e8f0"/>` +
|
||||
`<circle cx="16" cy="23" r="1.5" fill="#e2e8f0"/>` +
|
||||
`<circle cx="22" cy="21" r="1.5" fill="#e2e8f0"/>` +
|
||||
`<circle cx="13" cy="27" r="1.5" fill="#e2e8f0"/>` +
|
||||
`<circle cx="19" cy="27" r="1.5" fill="#e2e8f0"/>`,
|
||||
);
|
||||
|
||||
/** Tornado / Funnel — spinning funnel shape */
|
||||
export const svgWeatherTornado = weatherSvg(
|
||||
`<ellipse cx="16" cy="6" rx="10" ry="4" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
|
||||
`<path d="M8,8 Q10,14 13,18 Q14,22 15,28" fill="none" stroke="#94a3b8" stroke-width="3" stroke-linecap="round"/>` +
|
||||
`<path d="M24,8 Q22,14 19,18 Q18,22 17,28" fill="none" stroke="#94a3b8" stroke-width="3" stroke-linecap="round"/>` +
|
||||
`<ellipse cx="16" cy="28" rx="2" ry="1" fill="#78716c"/>`,
|
||||
);
|
||||
|
||||
/** Wind — wavy lines */
|
||||
export const svgWeatherWind = weatherSvg(
|
||||
`<path d="M4,10 Q8,6 12,10 Q16,14 20,10 Q24,6 28,10" fill="none" stroke="#94a3b8" stroke-width="2" stroke-linecap="round"/>` +
|
||||
`<path d="M4,17 Q8,13 12,17 Q16,21 20,17 Q24,13 28,17" fill="none" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>` +
|
||||
`<path d="M6,24 Q10,20 14,24 Q18,28 22,24 Q26,20 30,24" fill="none" stroke="#64748b" stroke-width="2" stroke-linecap="round"/>`,
|
||||
);
|
||||
|
||||
/** Flood — water waves */
|
||||
export const svgWeatherFlood = weatherSvg(
|
||||
`<ellipse cx="16" cy="10" rx="10" ry="5" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
|
||||
`<path d="M2,20 Q6,16 10,20 Q14,24 18,20 Q22,16 26,20 Q30,24 34,20" fill="none" stroke="#3b82f6" stroke-width="2"/>` +
|
||||
`<path d="M0,26 Q4,22 8,26 Q12,30 16,26 Q20,22 24,26 Q28,30 32,26" fill="none" stroke="#2563eb" stroke-width="2"/>`,
|
||||
);
|
||||
|
||||
/** Heat — sun with radiating lines */
|
||||
export const svgWeatherHeat = weatherSvg(
|
||||
`<circle cx="16" cy="16" r="6" fill="#f59e0b" stroke="#d97706" stroke-width="1"/>` +
|
||||
`<line x1="16" y1="4" x2="16" y2="8" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
|
||||
`<line x1="16" y1="24" x2="16" y2="28" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
|
||||
`<line x1="4" y1="16" x2="8" y2="16" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
|
||||
`<line x1="24" y1="16" x2="28" y2="16" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
|
||||
`<line x1="7.5" y1="7.5" x2="10.3" y2="10.3" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
|
||||
`<line x1="21.7" y1="21.7" x2="24.5" y2="24.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
|
||||
`<line x1="24.5" y1="7.5" x2="21.7" y2="10.3" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
|
||||
`<line x1="10.3" y1="21.7" x2="7.5" y2="24.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>`,
|
||||
);
|
||||
|
||||
/** Fog — stacked horizontal lines */
|
||||
export const svgWeatherFog = weatherSvg(
|
||||
`<line x1="4" y1="10" x2="28" y2="10" stroke="#94a3b8" stroke-width="2.5" stroke-linecap="round"/>` +
|
||||
`<line x1="6" y1="15" x2="26" y2="15" stroke="#cbd5e1" stroke-width="2.5" stroke-linecap="round"/>` +
|
||||
`<line x1="4" y1="20" x2="28" y2="20" stroke="#94a3b8" stroke-width="2.5" stroke-linecap="round"/>` +
|
||||
`<line x1="8" y1="25" x2="24" y2="25" stroke="#cbd5e1" stroke-width="2.5" stroke-linecap="round"/>`,
|
||||
);
|
||||
|
||||
/** Generic weather alert — cloud with exclamation */
|
||||
export const svgWeatherGeneric = weatherSvg(
|
||||
`<ellipse cx="16" cy="12" rx="10" ry="6" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
|
||||
`<ellipse cx="12" cy="11" rx="6" ry="5" fill="#94a3b8"/>` +
|
||||
`<rect x="14.5" y="19" width="3" height="6" rx="1" fill="#f59e0b"/>` +
|
||||
`<circle cx="16" cy="28" r="1.8" fill="#f59e0b"/>`,
|
||||
);
|
||||
|
||||
/** Map event name keywords → weather icon ID */
|
||||
export function weatherIconId(event: string): string {
|
||||
const e = event.toLowerCase();
|
||||
if (e.includes('tornado') || e.includes('funnel')) return 'wx-tornado';
|
||||
if (e.includes('thunder') || e.includes('lightning') || e.includes('tstm')) return 'wx-thunderstorm';
|
||||
if (e.includes('snow') || e.includes('blizzard') || e.includes('winter') || e.includes('ice') || e.includes('freezing') || e.includes('frost') || e.includes('sleet')) return 'wx-snow';
|
||||
if (e.includes('flood') || e.includes('surge') || e.includes('tsunami') || e.includes('coastal')) return 'wx-flood';
|
||||
if (e.includes('wind') || e.includes('gale') || e.includes('hurricane') || e.includes('tropical') || e.includes('typhoon')) return 'wx-wind';
|
||||
if (e.includes('heat') || e.includes('excessive') || e.includes('fire') || e.includes('red flag')) return 'wx-heat';
|
||||
if (e.includes('fog') || e.includes('dense') || e.includes('smoke') || e.includes('haze')) return 'wx-fog';
|
||||
if (e.includes('rain') || e.includes('shower') || e.includes('drizzle')) return 'wx-rain';
|
||||
return 'wx-generic';
|
||||
}
|
||||
|
||||
/** All weather icon specs for preloading */
|
||||
export const WEATHER_ICON_SPECS: { id: string; svg: string }[] = [
|
||||
{ id: 'wx-thunderstorm', svg: svgWeatherThunderstorm },
|
||||
{ id: 'wx-rain', svg: svgWeatherRain },
|
||||
{ id: 'wx-snow', svg: svgWeatherSnow },
|
||||
{ id: 'wx-tornado', svg: svgWeatherTornado },
|
||||
{ id: 'wx-wind', svg: svgWeatherWind },
|
||||
{ id: 'wx-flood', svg: svgWeatherFlood },
|
||||
{ id: 'wx-heat', svg: svgWeatherHeat },
|
||||
{ id: 'wx-fog', svg: svgWeatherFog },
|
||||
{ id: 'wx-generic', svg: svgWeatherGeneric },
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Extracted from MaplibreViewer.tsx — pure data, no JSX
|
||||
|
||||
export const makeSatSvg = (color: string) => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" fill="${color}" stroke="#0a0e1a" stroke-width="0.5"/>
|
||||
<rect x="1" y="10" width="7" height="4" rx="1" fill="${color}" opacity="0.7" stroke="#0a0e1a" stroke-width="0.3"/>
|
||||
<rect x="16" y="10" width="7" height="4" rx="1" fill="${color}" opacity="0.7" stroke="#0a0e1a" stroke-width="0.3"/>
|
||||
@@ -10,19 +10,54 @@ export const makeSatSvg = (color: string) => {
|
||||
<line x1="16" y1="12" x2="23" y2="12" stroke="${color}" stroke-width="0.8"/>
|
||||
<circle cx="12" cy="12" r="1.5" fill="#fff" opacity="0.8"/>
|
||||
</svg>`;
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
};
|
||||
|
||||
export const MISSION_COLORS: Record<string, string> = {
|
||||
'military_recon': '#ff3333', 'military_sar': '#ff3333',
|
||||
'sar': '#00e5ff', 'sigint': '#ffffff',
|
||||
'navigation': '#4488ff', 'early_warning': '#ff00ff',
|
||||
'commercial_imaging': '#44ff44', 'space_station': '#ffdd00'
|
||||
military_recon: '#ff3333',
|
||||
military_sar: '#ff3333',
|
||||
sar: '#00e5ff',
|
||||
sigint: '#ffffff',
|
||||
navigation: '#4488ff',
|
||||
early_warning: '#ff00ff',
|
||||
commercial_imaging: '#44ff44',
|
||||
space_station: '#ffdd00',
|
||||
};
|
||||
|
||||
/** Special ISS icon — larger with built-in golden dashed halo ring */
|
||||
export const makeISSSvg = () => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="14" fill="none" stroke="#ffdd00" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.9"/>
|
||||
<rect x="13" y="13" width="6" height="6" rx="1" fill="#ffdd00" stroke="#0a0e1a" stroke-width="0.5"/>
|
||||
<rect x="3" y="14" width="9" height="4" rx="1" fill="#ffdd00" opacity="0.7" stroke="#0a0e1a" stroke-width="0.3"/>
|
||||
<rect x="20" y="14" width="9" height="4" rx="1" fill="#ffdd00" opacity="0.7" stroke="#0a0e1a" stroke-width="0.3"/>
|
||||
<line x1="12" y1="16" x2="3" y2="16" stroke="#ffdd00" stroke-width="0.8"/>
|
||||
<line x1="20" y1="16" x2="29" y2="16" stroke="#ffdd00" stroke-width="0.8"/>
|
||||
<circle cx="16" cy="16" r="1.5" fill="#fff" opacity="0.9"/>
|
||||
</svg>`;
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
};
|
||||
|
||||
/** Train icon SVG — small locomotive shape */
|
||||
export const makeTrainSvg = (color: string) => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
|
||||
<rect x="3" y="4" width="12" height="9" rx="2" fill="${color}" stroke="#0a0e1a" stroke-width="0.5"/>
|
||||
<rect x="5" y="6" width="3" height="2.5" rx="0.5" fill="#0a0e1a" opacity="0.5"/>
|
||||
<rect x="10" y="6" width="3" height="2.5" rx="0.5" fill="#0a0e1a" opacity="0.5"/>
|
||||
<circle cx="6" cy="14.5" r="1.3" fill="${color}" stroke="#0a0e1a" stroke-width="0.3"/>
|
||||
<circle cx="12" cy="14.5" r="1.3" fill="${color}" stroke="#0a0e1a" stroke-width="0.3"/>
|
||||
<line x1="9" y1="10" x2="9" y2="12" stroke="#fff" stroke-width="0.8" opacity="0.6"/>
|
||||
</svg>`;
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
};
|
||||
|
||||
export const MISSION_ICON_MAP: Record<string, string> = {
|
||||
'military_recon': 'sat-mil', 'military_sar': 'sat-mil',
|
||||
'sar': 'sat-sar', 'sigint': 'sat-sigint',
|
||||
'navigation': 'sat-nav', 'early_warning': 'sat-ew',
|
||||
'commercial_imaging': 'sat-com', 'space_station': 'sat-station'
|
||||
military_recon: 'sat-mil',
|
||||
military_sar: 'sat-mil',
|
||||
sar: 'sat-sar',
|
||||
sigint: 'sat-sigint',
|
||||
navigation: 'sat-nav',
|
||||
early_warning: 'sat-ew',
|
||||
commercial_imaging: 'sat-com',
|
||||
space_station: 'sat-station',
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user