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:
anoracleofra-code
2026-03-26 05:58:04 -06:00
parent d363013742
commit 668ce16dc7
363 changed files with 170456 additions and 23229 deletions
@@ -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',
}),
}),
);
});
});
+457 -223
View File
@@ -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);
});
});
+224 -48
View File
@@ -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);
}
+106
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+391 -319
View File
@@ -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>
);
}
+360 -173
View File
@@ -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&apos;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 &amp; 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">&hearts;</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&apos;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 &mdash; 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 &amp; 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">
&hearts;
</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)]">
{' '}
&mdash; {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;
}
+42 -36
View File
@@ -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;
+35
View File
@@ -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}
/>
);
}
+355 -295
View File
@@ -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;
+248 -228
View File
@@ -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;
+77
View File
@@ -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>
);
}
+66
View File
@@ -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">&asymp; {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">&times;</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">&apos;shadowbroker&apos; 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 &apos;shadowbroker&apos; 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 &apos;{chosenPersona}&apos; 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 &apos;{target}&apos; 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 &apos;help&apos; 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">&apos;help&apos;</span> to see available commands.</p>
<p>Type <span className="text-green-400 font-bold">&apos;gates&apos;</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}&deg;{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>
);
}
+401 -279
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+368 -76
View File
@@ -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 &amp; 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
+525 -178
View File
@@ -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">&gt;_ 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">&gt;_ 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>}
&gt;_ {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>&gt;_ {subItem.source}</span>
<div className="flex items-center justify-between text-[7.5px] uppercase font-bold">
<span className="text-white">&gt;_ {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>
+330 -265
View File
@@ -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
+493 -352
View File
@@ -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>
);
}
+171 -153
View File
@@ -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
+941
View File
@@ -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&apos;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
+61 -48
View File
@@ -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
+132 -112
View File
@@ -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;
+404 -243
View File
@@ -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 50500 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);
}
};
+34 -34
View File
@@ -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 } },
],
};
+105 -27
View File
@@ -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.
}
+171
View File
@@ -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);
}
+394 -26
View File
@@ -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(() => {
+79 -26
View File
@@ -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);
}, []);
-31
View File
@@ -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;
}
+20 -20
View File
@@ -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 (
+63
View File
@@ -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);
}
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