mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-09 07:43:59 +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,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',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Layer, Marker, Source } from 'react-map-gl/maplibre';
|
||||
|
||||
export function MeasurementLayers({
|
||||
measurePoints,
|
||||
}: {
|
||||
measurePoints: { lat: number; lng: number }[] | undefined;
|
||||
}) {
|
||||
if (!measurePoints || measurePoints.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{measurePoints.length >= 2 && (
|
||||
<Source
|
||||
id="measure-lines"
|
||||
type="geojson"
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: measurePoints.map((p) => [p.lng, p.lat]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Layer
|
||||
id="measure-lines-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#00ffff',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [4, 3],
|
||||
'line-opacity': 0.8,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{measurePoints.map((pt, idx) => (
|
||||
<Marker key={`measure-${idx}`} longitude={pt.lng} latitude={pt.lat} anchor="center">
|
||||
<div className="flex flex-col items-center pointer-events-none">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-cyan-400 animate-ping absolute opacity-20" />
|
||||
<div className="w-4 h-4 rounded-full bg-cyan-500 border-2 border-cyan-300 shadow-[0_0_12px_rgba(0,255,255,0.6)] flex items-center justify-center">
|
||||
<span className="text-[7px] font-mono font-bold text-black">{idx + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import {
|
||||
derivePublicMeshAddress,
|
||||
getNodeIdentity,
|
||||
hasSovereignty,
|
||||
signEvent,
|
||||
nextSequence,
|
||||
getPublicKeyAlgo,
|
||||
} from '@/mesh/meshIdentity';
|
||||
import { PROTOCOL_VERSION } from '@/mesh/meshProtocol';
|
||||
import { validateEventPayload } from '@/mesh/meshSchema';
|
||||
|
||||
const MESH_NODE_ID_RE = /^![0-9a-f]{8}$/i;
|
||||
|
||||
function isMeshtasticNodeId(value: string | undefined | null): boolean {
|
||||
return !!value && MESH_NODE_ID_RE.test(value.trim());
|
||||
}
|
||||
|
||||
/** Inline send-message form for SIGINT popups — routes via MeshRouter */
|
||||
export function SigintSendForm({
|
||||
destination,
|
||||
source,
|
||||
region,
|
||||
channel,
|
||||
}: {
|
||||
destination: string;
|
||||
source: string;
|
||||
region?: string;
|
||||
channel?: string;
|
||||
}) {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
const [detail, setDetail] = useState('');
|
||||
const [warningAck, setWarningAck] = useState(false);
|
||||
const [publicMeshAddress, setPublicMeshAddress] = useState('');
|
||||
|
||||
const isMesh = source === 'meshtastic';
|
||||
const isDirectMesh = isMesh && isMeshtasticNodeId(destination);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isMesh) {
|
||||
setPublicMeshAddress('');
|
||||
return;
|
||||
}
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
|
||||
setPublicMeshAddress('');
|
||||
return;
|
||||
}
|
||||
derivePublicMeshAddress(identity.nodeId)
|
||||
.then((addr) => {
|
||||
if (!cancelled) setPublicMeshAddress(addr);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPublicMeshAddress('');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isMesh]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!msg.trim()) return;
|
||||
if (isMesh && !warningAck) {
|
||||
setStatus('error');
|
||||
setDetail('acknowledge public-mesh notice first');
|
||||
return;
|
||||
}
|
||||
setStatus('sending');
|
||||
try {
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity || !hasSovereignty()) {
|
||||
setStatus('error');
|
||||
setDetail('identity required');
|
||||
return;
|
||||
}
|
||||
const sequence = nextSequence();
|
||||
const payload = {
|
||||
message: msg.trim(),
|
||||
destination,
|
||||
channel: channel || 'LongFast',
|
||||
priority: 'normal',
|
||||
ephemeral: false,
|
||||
transport_lock: isMesh ? 'meshtastic' : '',
|
||||
};
|
||||
const v = validateEventPayload('message', payload);
|
||||
if (!v.ok) {
|
||||
setStatus('error');
|
||||
setDetail(`invalid payload: ${v.reason}`);
|
||||
return;
|
||||
}
|
||||
const signature = await signEvent('message', identity.nodeId, sequence, payload);
|
||||
const res = await fetch(`${API_BASE}/api/mesh/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
destination,
|
||||
message: msg.trim(),
|
||||
sender_id: identity.nodeId,
|
||||
node_id: identity.nodeId,
|
||||
public_key: identity.publicKey,
|
||||
public_key_algo: getPublicKeyAlgo(),
|
||||
signature,
|
||||
sequence,
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
channel: channel || 'LongFast',
|
||||
priority: 'normal',
|
||||
ephemeral: false,
|
||||
...(isMesh ? { transport_lock: 'meshtastic' } : {}),
|
||||
...(region ? { credentials: { mesh_region: region } } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setStatus('sent');
|
||||
setDetail(data.routed_via || 'sent');
|
||||
setMsg('');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setDetail(data.route_reason || data.detail || 'Failed');
|
||||
}
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setDetail('Network error');
|
||||
}
|
||||
setTimeout(() => setStatus('idle'), 4000);
|
||||
};
|
||||
|
||||
const label = isMesh
|
||||
? isDirectMesh
|
||||
? `PUBLIC DIRECT TO ${destination.toUpperCase()}`
|
||||
: `PUBLIC BROADCAST TO ${(channel || 'LongFast').toUpperCase()} (${(region || '?').toUpperCase()})`
|
||||
: 'SEND MESSAGE via MESH ROUTER';
|
||||
const placeholder = isMesh
|
||||
? isDirectMesh
|
||||
? `Public direct message to ${destination}...`
|
||||
: `Broadcast to ${channel || 'LongFast'}...`
|
||||
: `Message ${destination}...`;
|
||||
|
||||
return (
|
||||
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/30">
|
||||
<div className="text-[8px] text-[#666] tracking-widest mb-1">{label}</div>
|
||||
{isMesh && (
|
||||
<div className="mb-1.5 rounded border border-amber-500/30 bg-amber-950/20 px-2 py-1.5">
|
||||
<div className="text-[8px] text-amber-300 tracking-widest">
|
||||
PUBLIC MESH NOTICE
|
||||
</div>
|
||||
<div className="text-[8px] text-amber-200/80 mt-0.5 leading-relaxed">
|
||||
These Meshtastic messages are public/degraded, not private. They may be intercepted,
|
||||
relayed, logged, or fail to deliver.
|
||||
</div>
|
||||
{publicMeshAddress && (
|
||||
<div className="text-[8px] text-amber-100/70 mt-1 font-mono">
|
||||
YOUR PUBLIC MESH ADDRESS: {publicMeshAddress.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<label className="mt-1 flex items-start gap-1.5 text-[8px] text-amber-100/80 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={warningAck}
|
||||
onChange={(e) => setWarningAck(e.target.checked)}
|
||||
className="mt-[1px]"
|
||||
/>
|
||||
<span>I understand this message is public and not private.</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={msg}
|
||||
onChange={(e) => setMsg(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder={placeholder}
|
||||
maxLength={200}
|
||||
className={`flex-1 bg-[#0a0e1a] border border-[var(--border-primary)] rounded px-2 py-1 text-[10px] text-white font-mono placeholder:text-[#444] focus:outline-none ${
|
||||
isMesh ? 'focus:border-green-500/50' : 'focus:border-cyan-500/50'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={status === 'sending' || !msg.trim() || (isMesh && !warningAck)}
|
||||
className={`px-2 py-1 rounded disabled:opacity-30 disabled:cursor-not-allowed transition-colors ${
|
||||
isMesh
|
||||
? 'bg-green-950/60 border border-green-500/30 hover:bg-green-900/60 hover:border-green-400 text-green-400'
|
||||
: 'bg-cyan-950/60 border border-cyan-500/30 hover:bg-cyan-900/60 hover:border-cyan-400 text-cyan-400'
|
||||
}`}
|
||||
title={
|
||||
isMesh
|
||||
? isDirectMesh
|
||||
? `Send public direct message to ${destination}`
|
||||
: `Broadcast to ${channel} channel`
|
||||
: 'Send via auto-routed mesh'
|
||||
}
|
||||
>
|
||||
<Send size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{status === 'sent' && (
|
||||
<div className="text-[8px] text-green-400 mt-0.5">Routed via {detail}</div>
|
||||
)}
|
||||
{status === 'error' && <div className="text-[8px] text-red-400 mt-0.5">{detail}</div>}
|
||||
{status === 'sending' && (
|
||||
<div className="text-[8px] text-cyan-400 mt-0.5 animate-pulse">Routing...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mini feed showing recent Meshtastic text messages + channel population stats */
|
||||
export function MeshtasticChannelFeed({ region, channel }: { region: string; channel: string }) {
|
||||
interface MeshtasticMessage {
|
||||
from?: string;
|
||||
to?: string;
|
||||
text?: string;
|
||||
timestamp?: string | number;
|
||||
}
|
||||
interface ChannelStats {
|
||||
total_nodes?: number;
|
||||
total_live?: number;
|
||||
total_api?: number;
|
||||
regions?: Record<string, { channels?: Record<string, number> }>;
|
||||
roots?: Record<string, { channels?: Record<string, number> }>;
|
||||
known_roots?: string[];
|
||||
}
|
||||
|
||||
const [messages, setMessages] = useState<MeshtasticMessage[]>([]);
|
||||
const [channelStats, setChannelStats] = useState<ChannelStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [publicMeshAddress, setPublicMeshAddress] = useState('');
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
|
||||
setPublicMeshAddress('');
|
||||
return;
|
||||
}
|
||||
derivePublicMeshAddress(identity.nodeId)
|
||||
.then((addr) => {
|
||||
if (!cancelled) setPublicMeshAddress(addr);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPublicMeshAddress('');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '20' });
|
||||
if (region) params.set('region', region);
|
||||
if (channel) params.set('channel', channel);
|
||||
const [msgRes, statsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/mesh/messages?${params}`),
|
||||
fetch(`${API_BASE}/api/mesh/channels`),
|
||||
]);
|
||||
if (msgRes.ok) setMessages(await msgRes.json());
|
||||
if (statsRes.ok) setChannelStats(await statsRes.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setLoading(false);
|
||||
}, [region, channel]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
intervalRef.current = setInterval(fetchData, 15000);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
// Extract stats for this Meshtastic root/region
|
||||
const regionData = channelStats?.roots?.[region] || channelStats?.regions?.[region];
|
||||
const regionChannels = regionData?.channels || {};
|
||||
const sortedChannels = Object.entries(regionChannels).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (loading)
|
||||
return <div className="text-[8px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 pt-1 border-t border-green-500/20">
|
||||
{/* Channel population — which channels are active in this region */}
|
||||
{sortedChannels.length > 0 && (
|
||||
<div className="mb-1.5">
|
||||
<div className="text-[8px] text-green-400/60 tracking-widest mb-0.5">
|
||||
ACTIVE CHANNELS — {region}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sortedChannels.map(([ch, count]) => (
|
||||
<span
|
||||
key={ch}
|
||||
className={`font-mono text-[8px] px-1.5 py-0.5 rounded border ${
|
||||
ch === channel
|
||||
? 'bg-green-900/50 text-green-300 border-green-500/40'
|
||||
: 'bg-slate-800/50 text-slate-400 border-slate-600/30'
|
||||
}`}
|
||||
>
|
||||
{ch} <span className="text-white/60">{count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{(channelStats?.total_nodes ?? 0) > 0 && (
|
||||
<div className="text-[8px] text-[#555] mt-0.5">
|
||||
{channelStats?.total_live} live + {channelStats?.total_api?.toLocaleString()} map nodes
|
||||
globally
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message feed */}
|
||||
{messages.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[8px] text-green-400/60 tracking-widest mb-1">
|
||||
MESSAGES — {channel} ({region})
|
||||
</div>
|
||||
<div className="max-h-[140px] overflow-y-auto space-y-0.5 scrollbar-thin">
|
||||
{messages.map((m: MeshtasticMessage, i: number) => {
|
||||
const directedToYou =
|
||||
!!publicMeshAddress &&
|
||||
typeof m.to === 'string' &&
|
||||
m.to.toLowerCase() === publicMeshAddress.toLowerCase();
|
||||
const sentByYou =
|
||||
!!publicMeshAddress &&
|
||||
typeof m.from === 'string' &&
|
||||
m.from.toLowerCase() === publicMeshAddress.toLowerCase();
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-[9px] font-mono py-0.5 px-1 rounded hover:bg-green-950/20 ${
|
||||
directedToYou ? 'bg-amber-950/20 border border-amber-500/20' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-green-400">{m.from || '???'}</span>
|
||||
{m.to && m.to !== 'broadcast' && (
|
||||
<span className="text-slate-500 ml-1">→ {m.to}</span>
|
||||
)}
|
||||
{sentByYou && (
|
||||
<span className="text-cyan-400 ml-1">YOU</span>
|
||||
)}
|
||||
{directedToYou && (
|
||||
<span className="text-amber-300 ml-1">TO YOU</span>
|
||||
)}
|
||||
<span className="text-white/70 ml-1.5">{m.text}</span>
|
||||
{m.timestamp && (
|
||||
<span className="text-[#444] ml-1">
|
||||
{new Date(m.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[8px] text-[#555]">No recent messages on {channel}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import {
|
||||
buildAirQualityGeoJSON,
|
||||
buildCctvGeoJSON,
|
||||
buildDataCentersGeoJSON,
|
||||
buildFirmsGeoJSON,
|
||||
buildFishingActivityGeoJSON,
|
||||
buildGdeltGeoJSON,
|
||||
buildInternetOutagesGeoJSON,
|
||||
buildKiwisdrGeoJSON,
|
||||
buildLiveuaGeoJSON,
|
||||
buildPskReporterGeoJSON,
|
||||
buildMilitaryBasesGeoJSON,
|
||||
buildPowerPlantsGeoJSON,
|
||||
buildSatnogsStationsGeoJSON,
|
||||
buildScannerGeoJSON,
|
||||
buildTrainsGeoJSON,
|
||||
buildVIIRSChangeNodesGeoJSON,
|
||||
buildVolcanoesGeoJSON,
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type {
|
||||
AirQualityStation,
|
||||
CCTVCamera,
|
||||
DataCenter,
|
||||
FireHotspot,
|
||||
FishingEvent,
|
||||
GDELTIncident,
|
||||
InternetOutage,
|
||||
KiwiSDR,
|
||||
LiveUAmapIncident,
|
||||
PSKSpot,
|
||||
MilitaryBase,
|
||||
PowerPlant,
|
||||
SatNOGSStation,
|
||||
Scanner,
|
||||
Train,
|
||||
VIIRSChangeNode,
|
||||
Volcano,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
type BoundsTuple = [number, number, number, number];
|
||||
type FC = GeoJSON.FeatureCollection | null;
|
||||
|
||||
export type StaticMapLayersDataPayload = {
|
||||
cctv?: CCTVCamera[];
|
||||
kiwisdr?: KiwiSDR[];
|
||||
pskReporter?: PSKSpot[];
|
||||
satnogsStations?: SatNOGSStation[];
|
||||
scanners?: Scanner[];
|
||||
firmsFires?: FireHotspot[];
|
||||
internetOutages?: InternetOutage[];
|
||||
datacenters?: DataCenter[];
|
||||
powerPlants?: PowerPlant[];
|
||||
viirsChangeNodes?: VIIRSChangeNode[];
|
||||
militaryBases?: MilitaryBase[];
|
||||
gdelt?: GDELTIncident[];
|
||||
liveuamap?: LiveUAmapIncident[];
|
||||
airQuality?: AirQualityStation[];
|
||||
volcanoes?: Volcano[];
|
||||
fishingActivity?: FishingEvent[];
|
||||
trains?: Train[];
|
||||
};
|
||||
|
||||
export type StaticMapLayersBuildPayload = {
|
||||
bounds: BoundsTuple;
|
||||
activeLayers: {
|
||||
cctv: boolean;
|
||||
kiwisdr: boolean;
|
||||
psk_reporter: boolean;
|
||||
satnogs: boolean;
|
||||
scanners: boolean;
|
||||
firms: boolean;
|
||||
internet_outages: boolean;
|
||||
datacenters: boolean;
|
||||
power_plants: boolean;
|
||||
viirs_nightlights: boolean;
|
||||
military_bases: boolean;
|
||||
global_incidents: boolean;
|
||||
air_quality: boolean;
|
||||
volcanoes: boolean;
|
||||
fishing_activity: boolean;
|
||||
trains: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type StaticMapLayersResult = {
|
||||
cctvGeoJSON: FC;
|
||||
kiwisdrGeoJSON: FC;
|
||||
pskReporterGeoJSON: FC;
|
||||
satnogsGeoJSON: FC;
|
||||
scannerGeoJSON: FC;
|
||||
firmsGeoJSON: FC;
|
||||
internetOutagesGeoJSON: FC;
|
||||
dataCentersGeoJSON: FC;
|
||||
powerPlantsGeoJSON: FC;
|
||||
viirsChangeNodesGeoJSON: FC;
|
||||
militaryBasesGeoJSON: FC;
|
||||
gdeltGeoJSON: FC;
|
||||
liveuaGeoJSON: FC;
|
||||
airQualityGeoJSON: FC;
|
||||
volcanoesGeoJSON: FC;
|
||||
fishingGeoJSON: FC;
|
||||
trainsGeoJSON: FC;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
let staticData: StaticMapLayersDataPayload = {};
|
||||
|
||||
function createInView(bounds: BoundsTuple) {
|
||||
return (lat: number, lng: number) =>
|
||||
lng >= bounds[0] && lng <= bounds[2] && lat >= bounds[1] && lat <= bounds[3];
|
||||
}
|
||||
|
||||
function buildStaticLayers(payload: StaticMapLayersBuildPayload): StaticMapLayersResult {
|
||||
const inView = createInView(payload.bounds);
|
||||
|
||||
return {
|
||||
cctvGeoJSON: payload.activeLayers.cctv ? buildCctvGeoJSON(staticData.cctv, inView) : null,
|
||||
kiwisdrGeoJSON: payload.activeLayers.kiwisdr ? buildKiwisdrGeoJSON(staticData.kiwisdr, inView) : null,
|
||||
pskReporterGeoJSON: payload.activeLayers.psk_reporter
|
||||
? buildPskReporterGeoJSON(staticData.pskReporter, inView)
|
||||
: null,
|
||||
satnogsGeoJSON: payload.activeLayers.satnogs
|
||||
? buildSatnogsStationsGeoJSON(staticData.satnogsStations, inView)
|
||||
: null,
|
||||
scannerGeoJSON: payload.activeLayers.scanners ? buildScannerGeoJSON(staticData.scanners, inView) : null,
|
||||
firmsGeoJSON: payload.activeLayers.firms ? buildFirmsGeoJSON(staticData.firmsFires) : null,
|
||||
internetOutagesGeoJSON: payload.activeLayers.internet_outages
|
||||
? buildInternetOutagesGeoJSON(staticData.internetOutages)
|
||||
: null,
|
||||
dataCentersGeoJSON: payload.activeLayers.datacenters
|
||||
? buildDataCentersGeoJSON(staticData.datacenters)
|
||||
: null,
|
||||
powerPlantsGeoJSON: payload.activeLayers.power_plants
|
||||
? buildPowerPlantsGeoJSON(staticData.powerPlants)
|
||||
: null,
|
||||
viirsChangeNodesGeoJSON: payload.activeLayers.viirs_nightlights
|
||||
? buildVIIRSChangeNodesGeoJSON(staticData.viirsChangeNodes)
|
||||
: null,
|
||||
militaryBasesGeoJSON: payload.activeLayers.military_bases
|
||||
? buildMilitaryBasesGeoJSON(staticData.militaryBases)
|
||||
: null,
|
||||
gdeltGeoJSON: payload.activeLayers.global_incidents ? buildGdeltGeoJSON(staticData.gdelt) : null,
|
||||
liveuaGeoJSON: payload.activeLayers.global_incidents
|
||||
? buildLiveuaGeoJSON(staticData.liveuamap, inView)
|
||||
: null,
|
||||
airQualityGeoJSON: payload.activeLayers.air_quality ? buildAirQualityGeoJSON(staticData.airQuality) : null,
|
||||
volcanoesGeoJSON: payload.activeLayers.volcanoes ? buildVolcanoesGeoJSON(staticData.volcanoes) : null,
|
||||
fishingGeoJSON: payload.activeLayers.fishing_activity
|
||||
? buildFishingActivityGeoJSON(staticData.fishingActivity)
|
||||
: null,
|
||||
trainsGeoJSON: payload.activeLayers.trains ? buildTrainsGeoJSON(staticData.trains) : null,
|
||||
};
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
const message = event.data;
|
||||
|
||||
try {
|
||||
if (message.action === 'sync_static_layers') {
|
||||
staticData = message.payload;
|
||||
const response: WorkerResponse = { id: message.id, ok: true, result: true };
|
||||
self.postMessage(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = buildStaticLayers(message.payload);
|
||||
const response: WorkerResponse = {
|
||||
id: message.id,
|
||||
ok: true,
|
||||
result,
|
||||
};
|
||||
self.postMessage(response);
|
||||
} catch (error) {
|
||||
const response: WorkerResponse = {
|
||||
id: message.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : 'unknown_worker_error',
|
||||
};
|
||||
self.postMessage(response);
|
||||
}
|
||||
};
|
||||
@@ -1,41 +1,41 @@
|
||||
export const darkStyle = {
|
||||
version: 8,
|
||||
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
"https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png",
|
||||
"https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png",
|
||||
"https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png",
|
||||
"https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png"
|
||||
],
|
||||
tileSize: 256
|
||||
}
|
||||
version: 8,
|
||||
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
},
|
||||
layers: [
|
||||
{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 },
|
||||
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }
|
||||
]
|
||||
},
|
||||
layers: [
|
||||
{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 },
|
||||
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } },
|
||||
],
|
||||
};
|
||||
|
||||
export const lightStyle = {
|
||||
version: 8,
|
||||
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
sources: {
|
||||
'carto-light': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
"https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
|
||||
"https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
|
||||
"https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
|
||||
"https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png"
|
||||
],
|
||||
tileSize: 256
|
||||
}
|
||||
version: 8,
|
||||
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||
sources: {
|
||||
'carto-light': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
},
|
||||
layers: [
|
||||
{ id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 },
|
||||
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }
|
||||
]
|
||||
},
|
||||
layers: [
|
||||
{ id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 },
|
||||
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { mergeData, setBackendStatus as setStoreBackendStatus } from "./useDataStore";
|
||||
|
||||
export type BackendStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
type FastDataProbe = {
|
||||
commercial_flights?: unknown[];
|
||||
military_flights?: unknown[];
|
||||
tracked_flights?: unknown[];
|
||||
ships?: unknown[];
|
||||
sigint?: unknown[];
|
||||
cctv?: unknown[];
|
||||
};
|
||||
|
||||
function hasMeaningfulFastData(json: FastDataProbe): boolean {
|
||||
return (
|
||||
(json.commercial_flights?.length || 0) > 100 ||
|
||||
(json.military_flights?.length || 0) > 25 ||
|
||||
(json.tracked_flights?.length || 0) > 10 ||
|
||||
(json.ships?.length || 0) > 100 ||
|
||||
(json.sigint?.length || 0) > 100 ||
|
||||
(json.cctv?.length || 0) > 100
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event name dispatched by page.tsx when a layer toggle changes.
|
||||
* useDataPolling listens for this to immediately refetch slow-tier data
|
||||
* so toggled layers (power plants, GDELT, etc.) appear without the usual
|
||||
* 120-second wait.
|
||||
*/
|
||||
export const LAYER_TOGGLE_EVENT = 'sb:layer-toggle';
|
||||
|
||||
/**
|
||||
* Polls the backend for fast and slow data tiers.
|
||||
*
|
||||
* Matches the proven GitHub polling pattern:
|
||||
* - Empty useEffect dependency array (no restarts on viewport change)
|
||||
* - No viewport bbox filtering (full data every poll)
|
||||
* - Adaptive startup polling (3s retry → 15s/120s steady state)
|
||||
* - ETag conditional requests for bandwidth savings
|
||||
* - AbortController for clean unmount
|
||||
* All data is fetched globally (no bbox filtering) — the backend returns its
|
||||
* full in-memory cache and MapLibre culls off-screen entities on the GPU.
|
||||
* This eliminates the "empty map when zooming out" lag.
|
||||
*
|
||||
* The AIS stream viewport POST (/api/viewport) is still handled separately
|
||||
* by useViewportBounds to limit upstream AIS ingestion.
|
||||
*/
|
||||
export function useDataPolling() {
|
||||
const dataRef = useRef<any>({});
|
||||
const [dataVersion, setDataVersion] = useState(0);
|
||||
const data = dataRef.current;
|
||||
|
||||
const [backendStatus, setBackendStatus] = useState<BackendStatus>('connecting');
|
||||
|
||||
const fastEtag = useRef<string | null>(null);
|
||||
const slowEtag = useRef<string | null>(null);
|
||||
|
||||
@@ -27,43 +49,84 @@ export function useDataPolling() {
|
||||
let hasData = false;
|
||||
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
const fastAbortRef = { current: null as AbortController | null };
|
||||
const slowAbortRef = { current: null as AbortController | null };
|
||||
|
||||
const fetchFastData = async () => {
|
||||
if (fastTimerId) {
|
||||
clearTimeout(fastTimerId);
|
||||
fastTimerId = null;
|
||||
}
|
||||
if (fastAbortRef.current) return;
|
||||
const controller = new AbortController();
|
||||
fastAbortRef.current = controller;
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
|
||||
if (res.status === 304) { setBackendStatus('connected'); scheduleNext('fast'); return; }
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status === 304) {
|
||||
setStoreBackendStatus('connected');
|
||||
scheduleNext('fast');
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
setBackendStatus('connected');
|
||||
setStoreBackendStatus('connected');
|
||||
fastEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
const flights = json.commercial_flights?.length || 0;
|
||||
if (flights > 100) hasData = true;
|
||||
mergeData(json);
|
||||
if (hasMeaningfulFastData(json)) hasData = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
setBackendStatus('disconnected');
|
||||
const aborted =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'name' in e &&
|
||||
(e as { name?: string }).name === 'AbortError';
|
||||
if (!aborted) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
setStoreBackendStatus('disconnected');
|
||||
}
|
||||
} finally {
|
||||
if (fastAbortRef.current === controller) {
|
||||
fastAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
scheduleNext('fast');
|
||||
};
|
||||
|
||||
const fetchSlowData = async () => {
|
||||
if (slowAbortRef.current) return;
|
||||
const controller = new AbortController();
|
||||
slowAbortRef.current = controller;
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers });
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status === 304) { scheduleNext('slow'); return; }
|
||||
if (res.ok) {
|
||||
slowEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
mergeData(json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
const aborted =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'name' in e &&
|
||||
(e as { name?: string }).name === 'AbortError';
|
||||
if (!aborted) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
}
|
||||
} finally {
|
||||
if (slowAbortRef.current === controller) {
|
||||
slowAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
scheduleNext('slow');
|
||||
};
|
||||
@@ -79,14 +142,29 @@ export function useDataPolling() {
|
||||
}
|
||||
};
|
||||
|
||||
// When a layer toggle fires, immediately refetch slow data so the user
|
||||
// doesn't wait up to 120s for power plants / GDELT / etc. to appear.
|
||||
const onLayerToggle = () => {
|
||||
slowEtag.current = null; // invalidate ETag → guarantees fresh payload
|
||||
if (slowTimerId) clearTimeout(slowTimerId);
|
||||
slowTimerId = null;
|
||||
fetchSlowData();
|
||||
};
|
||||
window.addEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
||||
|
||||
fetchFastData();
|
||||
fetchSlowData();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
||||
if (fastTimerId) clearTimeout(fastTimerId);
|
||||
if (slowTimerId) clearTimeout(slowTimerId);
|
||||
if (fastAbortRef.current) fastAbortRef.current.abort();
|
||||
if (slowAbortRef.current) slowAbortRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, dataVersion, backendStatus };
|
||||
// Data and backend status are now accessed via useDataStore hooks
|
||||
// (useDataKey, useDataKeys, useDataSnapshot, useBackendStatus).
|
||||
// This hook is a pure side-effect — it starts polling and writes to the store.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Granular reactive data store — replaces the monolithic `data` prop cascade.
|
||||
*
|
||||
* Components subscribe to individual keys via useDataKey("ships") or
|
||||
* useDataKeys(["ships", "sigint"]) and ONLY re-render when those specific
|
||||
* keys change. This eliminates the re-render cascade where every 15-second
|
||||
* fast poll forced all 8+ dashboard components to reconcile.
|
||||
*
|
||||
* Built on React 18 useSyncExternalStore — zero dependencies, tear-free reads.
|
||||
*/
|
||||
import { useSyncExternalStore, useRef, useMemo } from "react";
|
||||
import type { DashboardData } from "@/types/dashboard";
|
||||
import type { BackendStatus } from "./useDataPolling";
|
||||
|
||||
// ── Store singleton ──────────────────────────────────────────────────────
|
||||
type Listener = () => void;
|
||||
|
||||
/** Per-key listener sets — only listeners subscribed to changed keys fire. */
|
||||
const keyListeners = new Map<string, Set<Listener>>();
|
||||
/** Global listeners — fire on ANY key change (used by useDataSnapshot). */
|
||||
const globalListeners = new Set<Listener>();
|
||||
|
||||
const store: Record<string, unknown> = {};
|
||||
|
||||
let backendStatus: BackendStatus = "connecting";
|
||||
const statusListeners = new Set<Listener>();
|
||||
|
||||
// ── Write API (called from useDataPolling) ───────────────────────────────
|
||||
|
||||
/** Merge a partial payload into the store, notifying only affected keys. */
|
||||
export function mergeData(patch: Record<string, unknown>) {
|
||||
const changedKeys: string[] = [];
|
||||
for (const key of Object.keys(patch)) {
|
||||
const next = patch[key];
|
||||
if (store[key] !== next) {
|
||||
store[key] = next;
|
||||
changedKeys.push(key);
|
||||
}
|
||||
}
|
||||
// Notify per-key subscribers
|
||||
for (const key of changedKeys) {
|
||||
const set = keyListeners.get(key);
|
||||
if (set) for (const fn of set) fn();
|
||||
}
|
||||
// Notify global subscribers only if something actually changed
|
||||
if (changedKeys.length > 0) {
|
||||
for (const fn of globalListeners) fn();
|
||||
}
|
||||
}
|
||||
|
||||
export function setBackendStatus(next: BackendStatus) {
|
||||
if (backendStatus === next) return;
|
||||
backendStatus = next;
|
||||
for (const fn of statusListeners) fn();
|
||||
}
|
||||
|
||||
// ── Read API (hooks) ─────────────────────────────────────────────────────
|
||||
|
||||
/** Subscribe to a single data key. Component only re-renders when that key's
|
||||
* reference identity changes. */
|
||||
export function useDataKey<K extends keyof DashboardData>(key: K): DashboardData[K] {
|
||||
const subscribe = useMemo(() => {
|
||||
return (onStoreChange: Listener) => {
|
||||
let set = keyListeners.get(key as string);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
keyListeners.set(key as string, set);
|
||||
}
|
||||
set.add(onStoreChange);
|
||||
return () => {
|
||||
set!.delete(onStoreChange);
|
||||
if (set!.size === 0) keyListeners.delete(key as string);
|
||||
};
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
const getSnapshot = useMemo(() => {
|
||||
return () => store[key as string] as DashboardData[K];
|
||||
}, [key]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/** Subscribe to multiple keys. Returns a stable object whose identity only
|
||||
* changes when any of the subscribed keys change. */
|
||||
export function useDataKeys<K extends keyof DashboardData>(
|
||||
keys: readonly K[],
|
||||
): Pick<DashboardData, K> {
|
||||
// Stable key list — avoid re-subscribing on every render
|
||||
const keysRef = useRef(keys);
|
||||
const keysStr = keys.join(",");
|
||||
const stableKeys = useMemo(() => {
|
||||
keysRef.current = keys;
|
||||
return keys;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keysStr]);
|
||||
|
||||
const subscribe = useMemo(() => {
|
||||
return (onStoreChange: Listener) => {
|
||||
const unsubs: (() => void)[] = [];
|
||||
for (const key of stableKeys) {
|
||||
let set = keyListeners.get(key as string);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
keyListeners.set(key as string, set);
|
||||
}
|
||||
set.add(onStoreChange);
|
||||
unsubs.push(() => {
|
||||
set!.delete(onStoreChange);
|
||||
if (set!.size === 0) keyListeners.delete(key as string);
|
||||
});
|
||||
}
|
||||
return () => { for (const u of unsubs) u(); };
|
||||
};
|
||||
}, [stableKeys]);
|
||||
|
||||
// Build a snapshot object whose identity is stable across renders when the
|
||||
// underlying values haven't changed.
|
||||
const prevRef = useRef<Pick<DashboardData, K> | null>(null);
|
||||
const getSnapshot = useMemo(() => {
|
||||
return () => {
|
||||
const prev = prevRef.current;
|
||||
let same = prev !== null;
|
||||
const obj = {} as Record<string, unknown>;
|
||||
for (const key of stableKeys) {
|
||||
const val = store[key as string];
|
||||
obj[key as string] = val;
|
||||
if (same && prev![key as string as K] !== val) same = false;
|
||||
}
|
||||
if (same) return prev!;
|
||||
const next = obj as Pick<DashboardData, K>;
|
||||
prevRef.current = next;
|
||||
return next;
|
||||
};
|
||||
}, [stableKeys]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/** Subscribe to backend connection status. */
|
||||
export function useBackendStatus(): BackendStatus {
|
||||
const subscribe = useMemo(() => {
|
||||
return (onStoreChange: Listener) => {
|
||||
statusListeners.add(onStoreChange);
|
||||
return () => { statusListeners.delete(onStoreChange); };
|
||||
};
|
||||
}, []);
|
||||
const getSnapshot = useMemo(() => () => backendStatus, []);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/** Full snapshot — used only by components that genuinely need everything
|
||||
* (e.g. MaplibreViewer). Re-renders on ANY key change, same as before. */
|
||||
export function useDataSnapshot(): Record<string, unknown> {
|
||||
const prevRef = useRef<Record<string, unknown>>(store);
|
||||
const subscribe = useMemo(() => {
|
||||
return (onStoreChange: Listener) => {
|
||||
globalListeners.add(onStoreChange);
|
||||
return () => { globalListeners.delete(onStoreChange); };
|
||||
};
|
||||
}, []);
|
||||
const getSnapshot = useMemo(() => {
|
||||
return () => {
|
||||
// Return the same store reference — identity changes via globalListeners
|
||||
// already guarantee a re-render when mergeData is called.
|
||||
prevRef.current = store;
|
||||
return store;
|
||||
};
|
||||
}, []);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -1,38 +1,406 @@
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import type { RegionDossier, SelectedEntity } from "@/types/dashboard";
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import type { RegionDossier, SelectedEntity } from '@/types/dashboard';
|
||||
|
||||
// ─── CACHE ─────────────────────────────────────────────────────────────────
|
||||
// Simple in-memory cache keyed by rounded lat/lng (0.1° ≈ 11km grid), 24h TTL.
|
||||
const _dossierCache = new Map<string, { data: RegionDossier; ts: number }>();
|
||||
const CACHE_TTL = 86400_000; // 24 hours in ms
|
||||
|
||||
function getCached(lat: number, lng: number): RegionDossier | null {
|
||||
const key = `${Math.round(lat * 10) / 10}_${Math.round(lng * 10) / 10}`;
|
||||
const entry = _dossierCache.get(key);
|
||||
if (entry && Date.now() - entry.ts < CACHE_TTL) return entry.data;
|
||||
if (entry) _dossierCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCache(lat: number, lng: number, data: RegionDossier) {
|
||||
const key = `${Math.round(lat * 10) / 10}_${Math.round(lng * 10) / 10}`;
|
||||
_dossierCache.set(key, { data, ts: Date.now() });
|
||||
// Evict oldest entries if cache exceeds 500
|
||||
if (_dossierCache.size > 500) {
|
||||
const oldest = _dossierCache.keys().next().value;
|
||||
if (oldest) _dossierCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ESRI WORLD IMAGERY FALLBACK ───────────────────────────────────────────
|
||||
function buildLocalSentinelFallback(lat: number, lng: number) {
|
||||
const latSpan = 0.18;
|
||||
const lngSpan = 0.24;
|
||||
const bbox = `${lng - lngSpan},${lat - latSpan},${lng + lngSpan},${lat + latSpan}`;
|
||||
const base =
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export';
|
||||
return {
|
||||
found: true,
|
||||
scene_id: null,
|
||||
datetime: null,
|
||||
cloud_cover: null,
|
||||
thumbnail_url: `${base}?bbox=${bbox}&bboxSR=4326&imageSR=4326&size=640,360&format=png32&f=image`,
|
||||
fullres_url: `${base}?bbox=${bbox}&bboxSR=4326&imageSR=4326&size=1600,900&format=png32&f=image`,
|
||||
bbox: [lng - lngSpan, lat - latSpan, lng + lngSpan, lat + latSpan],
|
||||
platform: 'Esri World Imagery',
|
||||
fallback: true,
|
||||
message: 'Using local imagery fallback while live satellite search completes.',
|
||||
};
|
||||
}
|
||||
|
||||
function buildLimitedDossier(lat: number, lng: number, error?: string): RegionDossier {
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
coordinates: { lat, lng },
|
||||
location: {
|
||||
display_name: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
},
|
||||
country: {
|
||||
name: 'LIMITED INTEL',
|
||||
official_name: '',
|
||||
leader: 'Unknown',
|
||||
government_type: 'Unavailable',
|
||||
population: 0,
|
||||
capital: 'Unknown',
|
||||
languages: [],
|
||||
currencies: [],
|
||||
region: '',
|
||||
subregion: '',
|
||||
area_km2: 0,
|
||||
flag_emoji: '',
|
||||
},
|
||||
local: {
|
||||
name: 'Selected coordinates',
|
||||
state: '',
|
||||
description: 'Fallback dossier',
|
||||
summary:
|
||||
'Live region enrichment is currently unavailable or slow. Local coordinates and fallback imagery are still available.',
|
||||
thumbnail: '',
|
||||
},
|
||||
warning: error || 'Region dossier is using local fallback data.',
|
||||
} as RegionDossier;
|
||||
}
|
||||
|
||||
// ─── BROWSER-DIRECT API CALLS ──────────────────────────────────────────────
|
||||
// All external APIs below support CORS — no backend proxy needed.
|
||||
|
||||
/** Reverse geocode via Nominatim (direct browser call). */
|
||||
async function reverseGeocode(lat: number, lng: number) {
|
||||
const url =
|
||||
`https://nominatim.openstreetmap.org/reverse?` +
|
||||
`lat=${lat}&lon=${lng}&format=json&zoom=10&addressdetails=1&accept-language=en`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'ShadowBroker-OSINT/1.0 (live-risk-dashboard)' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Nominatim HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const addr = data.address || {};
|
||||
return {
|
||||
city: addr.city || addr.town || addr.village || addr.county || '',
|
||||
state: addr.state || addr.region || '',
|
||||
country: addr.country || '',
|
||||
country_code: (addr.country_code || '').toUpperCase(),
|
||||
display_name: data.display_name || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetch country data from RestCountries (direct browser call). */
|
||||
async function fetchCountryData(countryCode: string) {
|
||||
if (!countryCode) return {};
|
||||
const url =
|
||||
`https://restcountries.com/v3.1/alpha/${countryCode}` +
|
||||
`?fields=name,population,capital,languages,region,subregion,area,currencies,borders,flag`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`RestCountries HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data[0] || {} : data || {};
|
||||
}
|
||||
|
||||
/** Fetch head of state + government type from Wikidata SPARQL (direct browser call). */
|
||||
async function fetchLeader(countryName: string) {
|
||||
if (!countryName) return { leader: 'Unknown', government_type: 'Unknown' };
|
||||
const safeName = countryName.replace(/"/g, '\\"').replace(/'/g, "\\'");
|
||||
const sparql = `
|
||||
SELECT ?leaderLabel ?govTypeLabel WHERE {
|
||||
?country wdt:P31 wd:Q6256 ;
|
||||
rdfs:label "${safeName}"@en .
|
||||
OPTIONAL { ?country wdt:P35 ?leader . }
|
||||
OPTIONAL { ?country wdt:P122 ?govType . }
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||
} LIMIT 1
|
||||
`;
|
||||
const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparql)}&format=json`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/sparql-results+json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Wikidata HTTP ${res.status}`);
|
||||
const results = (await res.json()).results?.bindings || [];
|
||||
if (results.length > 0) {
|
||||
return {
|
||||
leader: results[0].leaderLabel?.value || 'Unknown',
|
||||
government_type: results[0].govTypeLabel?.value || 'Unknown',
|
||||
};
|
||||
}
|
||||
return { leader: 'Unknown', government_type: 'Unknown' };
|
||||
}
|
||||
|
||||
/** Fetch Wikipedia summary for a place (direct browser call). */
|
||||
async function fetchLocalWikiSummary(placeName: string, countryName = '') {
|
||||
if (!placeName) return {};
|
||||
const candidates = [placeName];
|
||||
if (countryName) candidates.push(`${placeName}, ${countryName}`);
|
||||
|
||||
for (const name of candidates) {
|
||||
try {
|
||||
const slug = encodeURIComponent(name.replace(/ /g, '_'));
|
||||
const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${slug}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json();
|
||||
if (data.type === 'disambiguation') continue;
|
||||
return {
|
||||
description: data.description || '',
|
||||
extract: data.extract || '',
|
||||
thumbnail: data.thumbnail?.source || '',
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Search for Sentinel-2 imagery via Microsoft Planetary Computer STAC (direct browser call). */
|
||||
async function fetchSentinel2Direct(lat: number, lng: number) {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const payload = {
|
||||
collections: ['sentinel-2-l2a'],
|
||||
intersects: { type: 'Point', coordinates: [lng, lat] },
|
||||
datetime: `${thirtyDaysAgo.toISOString()}/${now.toISOString()}`,
|
||||
sortby: [{ field: 'datetime', direction: 'desc' }],
|
||||
limit: 3,
|
||||
query: { 'eo:cloud_cover': { lt: 30 } },
|
||||
};
|
||||
|
||||
const res = await fetch('https://planetarycomputer.microsoft.com/api/stac/v1/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Planetary Computer HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const features = data.features || [];
|
||||
if (!features.length) return null; // No scenes — caller uses Esri fallback
|
||||
|
||||
const item = features[0];
|
||||
const assets = item.assets || {};
|
||||
const rendered = assets.rendered_preview || {};
|
||||
const thumbnail = assets.thumbnail || {};
|
||||
|
||||
return {
|
||||
found: true,
|
||||
scene_id: item.id,
|
||||
datetime: item.properties?.datetime,
|
||||
cloud_cover: item.properties?.['eo:cloud_cover'],
|
||||
thumbnail_url: thumbnail.href || rendered.href,
|
||||
fullres_url: rendered.href || thumbnail.href,
|
||||
bbox: item.bbox ? [...item.bbox] : null,
|
||||
platform: item.properties?.platform || 'Sentinel-2',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── MAIN HOOK ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useRegionDossier(
|
||||
selectedEntity: SelectedEntity | null,
|
||||
setSelectedEntity: (entity: SelectedEntity | null) => void
|
||||
setSelectedEntity: (entity: SelectedEntity | null) => void,
|
||||
) {
|
||||
const [regionDossier, setRegionDossier] = useState<RegionDossier | null>(null);
|
||||
const [regionDossierLoading, setRegionDossierLoading] = useState(false);
|
||||
|
||||
const handleMapRightClick = useCallback(async (coords: { lat: number; lng: number }) => {
|
||||
setSelectedEntity({ type: 'region_dossier', id: `${coords.lat.toFixed(4)}_${coords.lng.toFixed(4)}`, extra: coords });
|
||||
setRegionDossierLoading(true);
|
||||
setRegionDossier(null);
|
||||
try {
|
||||
const [dossierRes, sentinelRes] = await Promise.allSettled([
|
||||
fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
fetch(`${API_BASE}/api/sentinel2/search?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
]);
|
||||
let dossierData: Record<string, unknown> = {};
|
||||
if (dossierRes.status === 'fulfilled' && dossierRes.value.ok) {
|
||||
dossierData = await dossierRes.value.json();
|
||||
const handleMapRightClick = useCallback(
|
||||
async (coords: { lat: number; lng: number }) => {
|
||||
const { lat, lng } = coords;
|
||||
const esriFallback = buildLocalSentinelFallback(lat, lng);
|
||||
|
||||
setSelectedEntity({
|
||||
type: 'region_dossier',
|
||||
id: `${lat.toFixed(4)}_${lng.toFixed(4)}`,
|
||||
extra: coords,
|
||||
});
|
||||
setRegionDossierLoading(true);
|
||||
|
||||
// Check cache first
|
||||
const cached = getCached(lat, lng);
|
||||
if (cached) {
|
||||
setRegionDossier(cached);
|
||||
setRegionDossierLoading(false);
|
||||
return;
|
||||
}
|
||||
let sentinelData = null;
|
||||
if (sentinelRes.status === 'fulfilled' && sentinelRes.value.ok) {
|
||||
sentinelData = await sentinelRes.value.json();
|
||||
|
||||
// Show fallback immediately while API calls are in flight
|
||||
setRegionDossier({
|
||||
...buildLimitedDossier(lat, lng),
|
||||
sentinel2: esriFallback,
|
||||
});
|
||||
|
||||
try {
|
||||
// ── Phase 1: Geocode + Sentinel-2 in parallel ──────────────────
|
||||
const [geoResult, sentinelResult] = await Promise.allSettled([
|
||||
reverseGeocode(lat, lng),
|
||||
fetchSentinel2Direct(lat, lng),
|
||||
]);
|
||||
|
||||
// Parse geocode
|
||||
let geo = { city: '', state: '', country: '', country_code: '', display_name: '' };
|
||||
if (geoResult.status === 'fulfilled') {
|
||||
geo = geoResult.value;
|
||||
} else {
|
||||
console.warn('[Dossier] Reverse geocode failed:', geoResult.reason);
|
||||
}
|
||||
|
||||
// Parse sentinel
|
||||
let sentinel2: Record<string, unknown> = esriFallback;
|
||||
if (sentinelResult.status === 'fulfilled' && sentinelResult.value) {
|
||||
sentinel2 = sentinelResult.value;
|
||||
} else if (sentinelResult.status === 'rejected') {
|
||||
console.warn('[Dossier] Sentinel-2 search failed:', sentinelResult.reason);
|
||||
}
|
||||
// sentinelResult fulfilled but null → no scenes found, keep Esri fallback
|
||||
|
||||
// If no country found (ocean, uninhabited), show limited dossier
|
||||
if (!geo.country) {
|
||||
const result: RegionDossier = {
|
||||
lat,
|
||||
lng,
|
||||
coordinates: { lat, lng },
|
||||
location: geo.display_name
|
||||
? geo
|
||||
: { display_name: `${lat.toFixed(4)}, ${lng.toFixed(4)}` },
|
||||
country: null,
|
||||
local: null,
|
||||
error: 'No country data — possibly international waters or uninhabited area',
|
||||
sentinel2,
|
||||
} as RegionDossier;
|
||||
setRegionDossier(result);
|
||||
setCache(lat, lng, result);
|
||||
setRegionDossierLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Phase 2: Country + Leader + Wiki in parallel ───────────────
|
||||
const [countryResult, leaderResult, localWikiResult, countryWikiResult] =
|
||||
await Promise.allSettled([
|
||||
fetchCountryData(geo.country_code),
|
||||
fetchLeader(geo.country),
|
||||
fetchLocalWikiSummary(geo.city || geo.state, geo.country),
|
||||
fetchLocalWikiSummary(geo.country, ''),
|
||||
]);
|
||||
|
||||
// Parse country data
|
||||
let countryData: Record<string, unknown> = {};
|
||||
if (countryResult.status === 'fulfilled') {
|
||||
countryData = countryResult.value as Record<string, unknown>;
|
||||
} else {
|
||||
console.warn('[Dossier] Country data failed:', countryResult.reason);
|
||||
}
|
||||
|
||||
// Parse leader data
|
||||
let leaderData = { leader: 'Unknown', government_type: 'Unknown' };
|
||||
if (leaderResult.status === 'fulfilled') {
|
||||
leaderData = leaderResult.value;
|
||||
} else {
|
||||
console.warn('[Dossier] Leader data failed:', leaderResult.reason);
|
||||
}
|
||||
|
||||
// Parse local wiki
|
||||
let localData: Record<string, string> = {};
|
||||
if (localWikiResult.status === 'fulfilled') {
|
||||
localData = localWikiResult.value as Record<string, string>;
|
||||
} else {
|
||||
console.warn('[Dossier] Local wiki failed:', localWikiResult.reason);
|
||||
}
|
||||
|
||||
// If no local data, try country wiki summary
|
||||
if (!localData.extract && countryWikiResult.status === 'fulfilled') {
|
||||
const cw = countryWikiResult.value as Record<string, string>;
|
||||
if (cw.extract) localData = cw;
|
||||
}
|
||||
|
||||
// Build languages list
|
||||
const languages = countryData.languages as Record<string, string> | undefined;
|
||||
const langList = languages ? Object.values(languages) : [];
|
||||
|
||||
// Build currencies list
|
||||
const currencies = countryData.currencies as
|
||||
| Record<string, { name: string; symbol?: string }>
|
||||
| undefined;
|
||||
const currencyList: string[] = [];
|
||||
if (currencies) {
|
||||
for (const v of Object.values(currencies)) {
|
||||
if (v && typeof v === 'object') {
|
||||
const sym = v.symbol || '';
|
||||
const nm = v.name || '';
|
||||
currencyList.push(sym ? `${nm} (${sym})` : nm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nameData = countryData.name as
|
||||
| { common?: string; official?: string }
|
||||
| undefined;
|
||||
const capitalData = countryData.capital as string[] | undefined;
|
||||
|
||||
// ── Assemble final dossier (exact same shape as backend) ───────
|
||||
const result: RegionDossier = {
|
||||
lat,
|
||||
lng,
|
||||
coordinates: { lat, lng },
|
||||
location: {
|
||||
city: geo.city,
|
||||
state: geo.state,
|
||||
country: geo.country,
|
||||
country_code: geo.country_code,
|
||||
display_name: geo.display_name,
|
||||
},
|
||||
country: {
|
||||
name: nameData?.common || geo.country,
|
||||
official_name: nameData?.official || '',
|
||||
leader: leaderData.leader,
|
||||
government_type: leaderData.government_type,
|
||||
population: (countryData.population as number) || 0,
|
||||
capital: capitalData?.length ? capitalData[0] : 'Unknown',
|
||||
languages: langList,
|
||||
currencies: currencyList,
|
||||
region: (countryData.region as string) || '',
|
||||
subregion: (countryData.subregion as string) || '',
|
||||
area_km2: (countryData.area as number) || 0,
|
||||
flag_emoji: (countryData.flag as string) || '',
|
||||
},
|
||||
local: {
|
||||
name: geo.city,
|
||||
state: geo.state,
|
||||
description: localData.description || '',
|
||||
summary: localData.extract || '',
|
||||
thumbnail: localData.thumbnail || '',
|
||||
},
|
||||
sentinel2,
|
||||
} as RegionDossier;
|
||||
|
||||
setRegionDossier(result);
|
||||
setCache(lat, lng, result);
|
||||
} catch (e) {
|
||||
console.error('[Dossier] Unexpected error:', e);
|
||||
setRegionDossier({
|
||||
...buildLimitedDossier(lat, lng, 'Region dossier request failed unexpectedly'),
|
||||
sentinel2: esriFallback,
|
||||
});
|
||||
} finally {
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
setRegionDossier({ lat: coords.lat, lng: coords.lng, ...dossierData, sentinel2: sentinelData });
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch region dossier", e);
|
||||
} finally {
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
}, [setSelectedEntity]);
|
||||
},
|
||||
[setSelectedEntity],
|
||||
);
|
||||
|
||||
// Clear dossier when selecting a different entity type
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,16 +1,65 @@
|
||||
import { useCallback, useState, useRef } from "react";
|
||||
import { GEOCODE_THROTTLE_MS, GEOCODE_DISTANCE_THRESHOLD, GEOCODE_CACHE_SIZE } from "@/lib/constants";
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
GEOCODE_THROTTLE_MS,
|
||||
GEOCODE_DISTANCE_THRESHOLD,
|
||||
GEOCODE_CACHE_SIZE,
|
||||
} from '@/lib/constants';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
|
||||
const REVERSE_GEOCODE_TIMEOUT_MS = 1200;
|
||||
const REVERSE_GEOCODE_MIN_INTERVAL_MS = 2500;
|
||||
const REVERSE_GEOCODE_GRID_DECIMALS = 1;
|
||||
const MOUSE_COORDS_UI_INTERVAL_MS = 80;
|
||||
const MOUSE_COORDS_DISPLAY_DECIMALS = 4;
|
||||
|
||||
async function fetchJsonWithTimeout(url: string, timeoutMs: number, signal?: AbortSignal) {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export function useReverseGeocode() {
|
||||
const [mouseCoords, setMouseCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [locationLabel, setLocationLabel] = useState('');
|
||||
const geocodeCache = useRef<Map<string, string>>(new Map());
|
||||
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const coordsUiTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastGeocodedPos = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const geocodeAbort = useRef<AbortController | null>(null);
|
||||
const lastRequestAt = useRef(0);
|
||||
const lastUiCoordsKey = useRef('');
|
||||
const pendingUiCoords = useRef<{ lat: number; lng: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
|
||||
if (coordsUiTimer.current) clearTimeout(coordsUiTimer.current);
|
||||
if (geocodeAbort.current) geocodeAbort.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseCoords = useCallback((coords: { lat: number; lng: number }) => {
|
||||
setMouseCoords(coords);
|
||||
pendingUiCoords.current = coords;
|
||||
if (!coordsUiTimer.current) {
|
||||
coordsUiTimer.current = setTimeout(() => {
|
||||
coordsUiTimer.current = null;
|
||||
const next = pendingUiCoords.current;
|
||||
if (!next) return;
|
||||
const uiKey = `${next.lat.toFixed(MOUSE_COORDS_DISPLAY_DECIMALS)},${next.lng.toFixed(MOUSE_COORDS_DISPLAY_DECIMALS)}`;
|
||||
if (uiKey === lastUiCoordsKey.current) return;
|
||||
lastUiCoordsKey.current = uiKey;
|
||||
setMouseCoords(next);
|
||||
}, MOUSE_COORDS_UI_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
|
||||
geocodeTimer.current = setTimeout(async () => {
|
||||
@@ -20,7 +69,7 @@ export function useReverseGeocode() {
|
||||
if (dLat < GEOCODE_DISTANCE_THRESHOLD && dLng < GEOCODE_DISTANCE_THRESHOLD) return;
|
||||
}
|
||||
|
||||
const gridKey = `${(coords.lat).toFixed(2)},${(coords.lng).toFixed(2)}`;
|
||||
const gridKey = `${coords.lat.toFixed(REVERSE_GEOCODE_GRID_DECIMALS)},${coords.lng.toFixed(REVERSE_GEOCODE_GRID_DECIMALS)}`;
|
||||
const cached = geocodeCache.current.get(gridKey);
|
||||
if (cached) {
|
||||
setLocationLabel(cached);
|
||||
@@ -28,36 +77,40 @@ export function useReverseGeocode() {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastRequestAt.current < REVERSE_GEOCODE_MIN_INTERVAL_MS) return;
|
||||
lastRequestAt.current = now;
|
||||
|
||||
if (geocodeAbort.current) geocodeAbort.current.abort();
|
||||
geocodeAbort.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lng}&format=json&zoom=10&addressdetails=1`,
|
||||
{ headers: { 'Accept-Language': 'en' }, signal: geocodeAbort.current.signal }
|
||||
const data = await fetchJsonWithTimeout(
|
||||
`${API_BASE}/api/geocode/reverse?lat=${coords.lat}&lng=${coords.lng}&local_only=1`,
|
||||
REVERSE_GEOCODE_TIMEOUT_MS,
|
||||
geocodeAbort.current.signal,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const addr = data.address || {};
|
||||
const city = addr.city || addr.town || addr.village || addr.county || '';
|
||||
const state = addr.state || addr.region || '';
|
||||
const country = addr.country || '';
|
||||
const parts = [city, state, country].filter(Boolean);
|
||||
const label = parts.join(', ') || data.display_name?.split(',').slice(0, 3).join(',') || 'Unknown';
|
||||
const label = data?.label || 'Unknown';
|
||||
|
||||
if (geocodeCache.current.size > GEOCODE_CACHE_SIZE) {
|
||||
const iter = geocodeCache.current.keys();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) geocodeCache.current.delete(key);
|
||||
}
|
||||
if (geocodeCache.current.size > GEOCODE_CACHE_SIZE) {
|
||||
const iter = geocodeCache.current.keys();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) geocodeCache.current.delete(key);
|
||||
}
|
||||
geocodeCache.current.set(gridKey, label);
|
||||
setLocationLabel(label);
|
||||
lastGeocodedPos.current = coords;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') { /* Silently fail - keep last label */ }
|
||||
geocodeCache.current.set(gridKey, label);
|
||||
setLocationLabel(label);
|
||||
lastGeocodedPos.current = coords;
|
||||
} catch (err) {
|
||||
const isAbort =
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
(err as { name?: string }).name === 'AbortError';
|
||||
if (!isAbort) {
|
||||
/* Silently fail - keep last label */
|
||||
}
|
||||
}
|
||||
}, GEOCODE_THROTTLE_MS);
|
||||
}, []);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext } from "react";
|
||||
import type { DashboardData } from "@/types/dashboard";
|
||||
|
||||
interface DashboardDataContextValue {
|
||||
data: DashboardData;
|
||||
selectedEntity: { id: string | number; type: string; extra?: any } | null;
|
||||
setSelectedEntity: (entity: { id: string | number; type: string; extra?: any } | null) => void;
|
||||
}
|
||||
|
||||
const DashboardDataContext = createContext<DashboardDataContextValue | null>(null);
|
||||
|
||||
export function DashboardDataProvider({
|
||||
data,
|
||||
selectedEntity,
|
||||
setSelectedEntity,
|
||||
children,
|
||||
}: DashboardDataContextValue & { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardDataContext.Provider value={{ data, selectedEntity, setSelectedEntity }}>
|
||||
{children}
|
||||
</DashboardDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboardData(): DashboardDataContextValue {
|
||||
const ctx = useContext(DashboardDataContext);
|
||||
if (!ctx) throw new Error("useDashboardData must be used within DashboardDataProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
type HudColor = "cyan" | "matrix";
|
||||
type Theme = 'dark' | 'light';
|
||||
type HudColor = 'cyan' | 'matrix';
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
theme: Theme;
|
||||
@@ -11,41 +11,41 @@ const ThemeContext = createContext<{
|
||||
hudColor: HudColor;
|
||||
cycleHudColor: () => void;
|
||||
}>({
|
||||
theme: "dark",
|
||||
theme: 'dark',
|
||||
toggleTheme: () => {},
|
||||
hudColor: "cyan",
|
||||
hudColor: 'cyan',
|
||||
cycleHudColor: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
const [hudColor, setHudColor] = useState<HudColor>("cyan");
|
||||
const [theme, setTheme] = useState<Theme>('dark');
|
||||
const [hudColor, setHudColor] = useState<HudColor>('cyan');
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("sb-theme") as Theme | null;
|
||||
if (saved === "light" || saved === "dark") {
|
||||
const saved = localStorage.getItem('sb-theme') as Theme | null;
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
setTheme(saved);
|
||||
document.documentElement.setAttribute("data-theme", saved);
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
}
|
||||
const savedHud = localStorage.getItem("sb-hud-color") as HudColor | null;
|
||||
if (savedHud === "cyan" || savedHud === "matrix") {
|
||||
const savedHud = localStorage.getItem('sb-hud-color') as HudColor | null;
|
||||
if (savedHud === 'cyan' || savedHud === 'matrix') {
|
||||
setHudColor(savedHud);
|
||||
document.documentElement.setAttribute("data-hud", savedHud);
|
||||
document.documentElement.setAttribute('data-hud', savedHud);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = theme === "dark" ? "light" : "dark";
|
||||
const next = theme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(next);
|
||||
localStorage.setItem("sb-theme", next);
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
localStorage.setItem('sb-theme', next);
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
};
|
||||
|
||||
const cycleHudColor = () => {
|
||||
const next = hudColor === "cyan" ? "matrix" : "cyan";
|
||||
const next = hudColor === 'cyan' ? 'matrix' : 'cyan';
|
||||
setHudColor(next);
|
||||
localStorage.setItem("sb-hud-color", next);
|
||||
document.documentElement.setAttribute("data-hud", next);
|
||||
localStorage.setItem('sb-hud-color', next);
|
||||
document.documentElement.setAttribute('data-hud', next);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { API_BASE } from '@/lib/api';
|
||||
|
||||
let hasPrimedSessionHint = false;
|
||||
|
||||
function takeLegacyAdminKey(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const sessionValue = sessionStorage.getItem('sb_admin_key') || '';
|
||||
const legacyValue = localStorage.getItem('sb_admin_key') || '';
|
||||
const candidate = sessionValue || legacyValue;
|
||||
try {
|
||||
sessionStorage.removeItem('sb_admin_key');
|
||||
localStorage.removeItem('sb_admin_key');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function hasAdminSession(): Promise<boolean> {
|
||||
try {
|
||||
const existing = await fetch(`${API_BASE}/api/admin/session`, { cache: 'no-store' });
|
||||
const existingData = await existing.json().catch(() => ({}));
|
||||
return Boolean(existing.ok && existingData?.hasSession);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function primeAdminSession(adminKey?: string): Promise<void> {
|
||||
if (!adminKey) {
|
||||
if (await hasAdminSession()) return;
|
||||
}
|
||||
const candidate = String(adminKey || takeLegacyAdminKey() || '').trim();
|
||||
if (!candidate) throw new Error('admin_session_required');
|
||||
if (hasPrimedSessionHint && (await hasAdminSession())) return;
|
||||
const res = await fetch(`${API_BASE}/api/admin/session`, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ adminKey: candidate }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.ok === false) {
|
||||
throw new Error(data?.detail || data?.message || 'admin_session_failed');
|
||||
}
|
||||
hasPrimedSessionHint = true;
|
||||
}
|
||||
|
||||
export async function clearAdminSession(): Promise<void> {
|
||||
hasPrimedSessionHint = false;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
sessionStorage.removeItem('sb_admin_key');
|
||||
localStorage.removeItem('sb_admin_key');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await fetch(`${API_BASE}/api/admin/session`, {
|
||||
method: 'DELETE',
|
||||
cache: 'no-store',
|
||||
}).catch(() => null);
|
||||
}
|
||||
+1026
-1026
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user