diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index 9afac6f..8ef6822 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -132,7 +132,7 @@ body { display: flex; gap: 20px; align-items: center; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; } @@ -624,7 +624,7 @@ body { } .telemetry-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; color: var(--accent-cyan); } @@ -680,7 +680,7 @@ body { } .aircraft-icao { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 9px; color: var(--text-secondary); background: rgba(74, 158, 255, 0.1); @@ -700,7 +700,7 @@ body { } .aircraft-detail-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); color: var(--accent-cyan); font-size: 11px; } @@ -790,7 +790,7 @@ body { border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; color: var(--accent-cyan); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; } @@ -801,7 +801,7 @@ body { border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; color: var(--accent-cyan); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; } @@ -879,7 +879,7 @@ body { border: none; background: var(--accent-green); color: #fff; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; @@ -911,7 +911,7 @@ body { border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; color: var(--accent-cyan); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; cursor: pointer; } @@ -1023,7 +1023,7 @@ body { cursor: pointer; font-size: 11px; font-weight: 600; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); display: flex; align-items: center; gap: 5px; @@ -1057,7 +1057,7 @@ body { } .airband-status { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; padding: 0 8px; color: var(--text-muted); @@ -1407,7 +1407,7 @@ body { } .strip-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: var(--accent-cyan); @@ -1545,7 +1545,7 @@ body { .report-grid span:nth-child(even) { color: var(--text-primary); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } .report-highlights { @@ -1784,7 +1784,7 @@ body { font-size: 11px; font-weight: 500; color: var(--accent-cyan); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); padding-left: 8px; border-left: 1px solid rgba(74, 158, 255, 0.2); white-space: nowrap; @@ -1938,7 +1938,7 @@ body { } .squawk-code { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-weight: 700; color: var(--accent-cyan); font-size: 12px; diff --git a/static/css/adsb_history.css b/static/css/adsb_history.css index 387cc3f..f24740b 100644 --- a/static/css/adsb_history.css +++ b/static/css/adsb_history.css @@ -27,7 +27,7 @@ body { } .mono { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } .radar-bg { @@ -91,7 +91,7 @@ body { display: flex; align-items: center; gap: 12px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; } @@ -268,7 +268,7 @@ body { } .status-pill { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; padding: 8px 12px; border-radius: 999px; @@ -306,7 +306,7 @@ body { } .panel-meta { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; color: var(--accent-cyan); } @@ -347,7 +347,7 @@ body { } .mono { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } .empty-row td, diff --git a/static/css/agents.css b/static/css/agents.css index 1d793f0..bece8d5 100644 --- a/static/css/agents.css +++ b/static/css/agents.css @@ -1,343 +1,343 @@ -/* - * Agents Management CSS - * Styles for the remote agent management interface - */ - -/* CSS Variables (inherited from main theme) */ -:root { - --bg-primary: #0a0a0f; - --bg-secondary: #12121a; - --text-primary: #e0e0e0; - --text-secondary: #888; - --border-color: #1a1a2e; - --accent-cyan: #00d4ff; - --accent-green: #00ff88; - --accent-red: #ff3366; - --accent-orange: #ff9f1c; -} - -/* Agent indicator in navigation */ -.agent-indicator { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - background: rgba(0, 212, 255, 0.1); - border: 1px solid rgba(0, 212, 255, 0.3); - border-radius: 20px; - cursor: pointer; - transition: all 0.2s; -} - -.agent-indicator:hover { - background: rgba(0, 212, 255, 0.2); - border-color: var(--accent-cyan); -} - -.agent-indicator-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent-green); - box-shadow: 0 0 6px var(--accent-green); -} - -.agent-indicator-dot.remote { - background: var(--accent-cyan); - box-shadow: 0 0 6px var(--accent-cyan); -} - -.agent-indicator-dot.multiple { - background: var(--accent-orange); - box-shadow: 0 0 6px var(--accent-orange); -} - -.agent-indicator-label { - font-size: 11px; - color: var(--text-primary); - font-family: 'JetBrains Mono', monospace; -} - -.agent-indicator-count { - font-size: 10px; - padding: 2px 6px; - background: rgba(0, 212, 255, 0.2); - border-radius: 10px; - color: var(--accent-cyan); -} - -/* Agent selector dropdown */ -.agent-selector { - position: relative; -} - -.agent-selector-dropdown { - position: absolute; - top: 100%; - right: 0; - margin-top: 8px; - min-width: 280px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - z-index: 1000; - display: none; -} - -.agent-selector-dropdown.show { - display: block; -} - -.agent-selector-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 15px; - border-bottom: 1px solid var(--border-color); -} - -.agent-selector-header h4 { - margin: 0; - font-size: 12px; - color: var(--accent-cyan); - text-transform: uppercase; - letter-spacing: 1px; -} - -.agent-selector-manage { - font-size: 11px; - color: var(--accent-cyan); - text-decoration: none; -} - -.agent-selector-manage:hover { - text-decoration: underline; -} - -.agent-selector-list { - max-height: 300px; - overflow-y: auto; -} - -.agent-selector-item { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 15px; - cursor: pointer; - transition: background 0.2s; - border-bottom: 1px solid var(--border-color); -} - -.agent-selector-item:last-child { - border-bottom: none; -} - -.agent-selector-item:hover { - background: rgba(0, 212, 255, 0.1); -} - -.agent-selector-item.selected { - background: rgba(0, 212, 255, 0.15); - border-left: 3px solid var(--accent-cyan); -} - -.agent-selector-item.local { - border-left: 3px solid var(--accent-green); -} - -.agent-selector-item-status { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.agent-selector-item-status.online { - background: var(--accent-green); -} - -.agent-selector-item-status.offline { - background: var(--accent-red); -} - -.agent-selector-item-info { - flex: 1; - min-width: 0; -} - -.agent-selector-item-name { - font-size: 13px; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.agent-selector-item-url { - font-size: 10px; - color: var(--text-secondary); - font-family: 'JetBrains Mono', monospace; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.agent-selector-item-check { - color: var(--accent-green); - opacity: 0; -} - -.agent-selector-item.selected .agent-selector-item-check { - opacity: 1; -} - -/* Agent badge in data displays */ -.agent-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - font-size: 10px; - background: rgba(0, 212, 255, 0.1); - color: var(--accent-cyan); - border-radius: 10px; - font-family: 'JetBrains Mono', monospace; -} - -.agent-badge.local, -.agent-badge.agent-local { - background: rgba(0, 255, 136, 0.1); - color: var(--accent-green); -} - -.agent-badge.agent-remote { - background: rgba(0, 212, 255, 0.1); - color: var(--accent-cyan); -} - -/* WiFi table agent column */ -.wifi-networks-table .col-agent { - width: 100px; - text-align: center; -} - -.wifi-networks-table th.col-agent { - font-size: 10px; -} - -/* Bluetooth table agent column */ -.bt-devices-table .col-agent { - width: 100px; - text-align: center; -} - -.agent-badge-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; -} - -/* Agent column in data tables */ -.data-table .agent-col { - width: 120px; - max-width: 120px; -} - -/* Multi-agent stream indicator */ -.multi-agent-indicator { - position: fixed; - bottom: 20px; - left: 20px; - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 20px; - font-size: 11px; - color: var(--text-secondary); - z-index: 100; -} - -.multi-agent-indicator.active { - border-color: var(--accent-cyan); - color: var(--accent-cyan); -} - -.multi-agent-indicator-pulse { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent-cyan); - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(0.8); } -} - -/* Agent connection status toast */ -.agent-toast { - position: fixed; - top: 80px; - right: 20px; - padding: 10px 15px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; - font-size: 12px; - z-index: 1001; - animation: slideInRight 0.3s ease; -} - -.agent-toast.connected { - border-color: var(--accent-green); - color: var(--accent-green); -} - -.agent-toast.disconnected { - border-color: var(--accent-red); - color: var(--accent-red); -} - -@keyframes slideInRight { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .agent-indicator { - padding: 4px 8px; - } - - .agent-indicator-label { - display: none; - } - - .agent-selector-dropdown { - position: fixed; - top: auto; - bottom: 0; - left: 0; - right: 0; - margin: 0; - border-radius: 16px 16px 0 0; - max-height: 60vh; - } - - .agents-grid { - grid-template-columns: 1fr; - } -} +/* + * Agents Management CSS + * Styles for the remote agent management interface + */ + +/* CSS Variables (inherited from main theme) */ +:root { + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --text-primary: #e0e0e0; + --text-secondary: #888; + --border-color: #1a1a2e; + --accent-cyan: #00d4ff; + --accent-green: #00ff88; + --accent-red: #ff3366; + --accent-orange: #ff9f1c; +} + +/* Agent indicator in navigation */ +.agent-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: rgba(0, 212, 255, 0.1); + border: 1px solid rgba(0, 212, 255, 0.3); + border-radius: 20px; + cursor: pointer; + transition: all 0.2s; +} + +.agent-indicator:hover { + background: rgba(0, 212, 255, 0.2); + border-color: var(--accent-cyan); +} + +.agent-indicator-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-green); + box-shadow: 0 0 6px var(--accent-green); +} + +.agent-indicator-dot.remote { + background: var(--accent-cyan); + box-shadow: 0 0 6px var(--accent-cyan); +} + +.agent-indicator-dot.multiple { + background: var(--accent-orange); + box-shadow: 0 0 6px var(--accent-orange); +} + +.agent-indicator-label { + font-size: 11px; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.agent-indicator-count { + font-size: 10px; + padding: 2px 6px; + background: rgba(0, 212, 255, 0.2); + border-radius: 10px; + color: var(--accent-cyan); +} + +/* Agent selector dropdown */ +.agent-selector { + position: relative; +} + +.agent-selector-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + min-width: 280px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + z-index: 1000; + display: none; +} + +.agent-selector-dropdown.show { + display: block; +} + +.agent-selector-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + border-bottom: 1px solid var(--border-color); +} + +.agent-selector-header h4 { + margin: 0; + font-size: 12px; + color: var(--accent-cyan); + text-transform: uppercase; + letter-spacing: 1px; +} + +.agent-selector-manage { + font-size: 11px; + color: var(--accent-cyan); + text-decoration: none; +} + +.agent-selector-manage:hover { + text-decoration: underline; +} + +.agent-selector-list { + max-height: 300px; + overflow-y: auto; +} + +.agent-selector-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 15px; + cursor: pointer; + transition: background 0.2s; + border-bottom: 1px solid var(--border-color); +} + +.agent-selector-item:last-child { + border-bottom: none; +} + +.agent-selector-item:hover { + background: rgba(0, 212, 255, 0.1); +} + +.agent-selector-item.selected { + background: rgba(0, 212, 255, 0.15); + border-left: 3px solid var(--accent-cyan); +} + +.agent-selector-item.local { + border-left: 3px solid var(--accent-green); +} + +.agent-selector-item-status { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.agent-selector-item-status.online { + background: var(--accent-green); +} + +.agent-selector-item-status.offline { + background: var(--accent-red); +} + +.agent-selector-item-info { + flex: 1; + min-width: 0; +} + +.agent-selector-item-name { + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agent-selector-item-url { + font-size: 10px; + color: var(--text-secondary); + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agent-selector-item-check { + color: var(--accent-green); + opacity: 0; +} + +.agent-selector-item.selected .agent-selector-item-check { + opacity: 1; +} + +/* Agent badge in data displays */ +.agent-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + font-size: 10px; + background: rgba(0, 212, 255, 0.1); + color: var(--accent-cyan); + border-radius: 10px; + font-family: var(--font-mono); +} + +.agent-badge.local, +.agent-badge.agent-local { + background: rgba(0, 255, 136, 0.1); + color: var(--accent-green); +} + +.agent-badge.agent-remote { + background: rgba(0, 212, 255, 0.1); + color: var(--accent-cyan); +} + +/* WiFi table agent column */ +.wifi-networks-table .col-agent { + width: 100px; + text-align: center; +} + +.wifi-networks-table th.col-agent { + font-size: 10px; +} + +/* Bluetooth table agent column */ +.bt-devices-table .col-agent { + width: 100px; + text-align: center; +} + +.agent-badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* Agent column in data tables */ +.data-table .agent-col { + width: 120px; + max-width: 120px; +} + +/* Multi-agent stream indicator */ +.multi-agent-indicator { + position: fixed; + bottom: 20px; + left: 20px; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 20px; + font-size: 11px; + color: var(--text-secondary); + z-index: 100; +} + +.multi-agent-indicator.active { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.multi-agent-indicator-pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-cyan); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.8); } +} + +/* Agent connection status toast */ +.agent-toast { + position: fixed; + top: 80px; + right: 20px; + padding: 10px 15px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 12px; + z-index: 1001; + animation: slideInRight 0.3s ease; +} + +.agent-toast.connected { + border-color: var(--accent-green); + color: var(--accent-green); +} + +.agent-toast.disconnected { + border-color: var(--accent-red); + color: var(--accent-red); +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .agent-indicator { + padding: 4px 8px; + } + + .agent-indicator-label { + display: none; + } + + .agent-selector-dropdown { + position: fixed; + top: auto; + bottom: 0; + left: 0; + right: 0; + margin: 0; + border-radius: 16px 16px 0 0; + max-height: 60vh; + } + + .agents-grid { + grid-template-columns: 1fr; + } +} diff --git a/static/css/ais_dashboard.css b/static/css/ais_dashboard.css index 3d4d268..8a8b7c0 100644 --- a/static/css/ais_dashboard.css +++ b/static/css/ais_dashboard.css @@ -134,7 +134,7 @@ body { display: flex; gap: 20px; align-items: center; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; } @@ -183,7 +183,7 @@ body { } .strip-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: var(--accent-cyan); @@ -287,7 +287,7 @@ body { font-size: 11px; font-weight: 500; color: var(--accent-cyan); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); padding-left: 8px; border-left: 1px solid rgba(74, 158, 255, 0.2); white-space: nowrap; @@ -367,7 +367,7 @@ body { /* Leaflet overrides - Dark map styling */ .leaflet-container { background: var(--bg-dark) !important; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } /* Using actual dark tiles now - no filter needed */ @@ -438,7 +438,7 @@ body { padding: 10px 15px; background: rgba(74, 158, 255, 0.05); border-bottom: 1px solid rgba(74, 158, 255, 0.1); - font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-family: 'Orbitron', 'Terminus', monospace; font-size: 11px; font-weight: 500; letter-spacing: 2px; @@ -510,7 +510,7 @@ body { } .vessel-name { - font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-family: 'Orbitron', 'Terminus', monospace; font-size: 16px; font-weight: 700; color: var(--accent-cyan); @@ -518,7 +518,7 @@ body { } .vessel-mmsi { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; color: var(--text-secondary); background: rgba(74, 158, 255, 0.1); @@ -548,7 +548,7 @@ body { } .detail-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; color: var(--accent-cyan); } @@ -604,20 +604,20 @@ body { } .vessel-item-name { - font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-family: 'Orbitron', 'Terminus', monospace; font-size: 12px; font-weight: 600; color: var(--accent-cyan); } .vessel-item-type { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 9px; color: var(--text-secondary); } .vessel-item-speed { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; color: var(--accent-cyan); text-align: right; @@ -687,7 +687,7 @@ body { border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; color: var(--accent-cyan); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; } @@ -698,7 +698,7 @@ body { border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; color: var(--accent-cyan); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; } @@ -717,7 +717,7 @@ body { border: none; background: var(--accent-green); color: #fff; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; @@ -1004,7 +1004,7 @@ body { padding: 6px 12px; background: rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(245, 158, 11, 0.1); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 9px; } @@ -1079,7 +1079,7 @@ body { } .dsc-message-category { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 9px; font-weight: 700; text-transform: uppercase; @@ -1096,13 +1096,13 @@ body { } .dsc-message-time { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); } .dsc-message-mmsi { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; color: var(--accent-orange); } @@ -1120,7 +1120,7 @@ body { } .dsc-message-pos { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 9px; color: var(--text-secondary); } @@ -1148,7 +1148,7 @@ body { } .dsc-distress-alert .dsc-alert-header { - font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-family: 'Orbitron', 'Terminus', monospace; font-size: 24px; font-weight: 700; color: var(--accent-red); @@ -1157,7 +1157,7 @@ body { } .dsc-distress-alert .dsc-alert-mmsi { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 16px; color: var(--accent-cyan); margin-bottom: 8px; @@ -1177,7 +1177,7 @@ body { } .dsc-distress-alert .dsc-alert-position { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 14px; color: var(--accent-cyan); margin-bottom: 16px; @@ -1188,7 +1188,7 @@ body { border: none; color: white; padding: 10px 24px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; font-weight: 600; text-transform: uppercase; diff --git a/static/css/components/activity-timeline.css b/static/css/components/activity-timeline.css index fa1bf4c..62443df 100644 --- a/static/css/components/activity-timeline.css +++ b/static/css/components/activity-timeline.css @@ -1,696 +1,696 @@ -/** - * Activity Timeline Component - * Reusable, configuration-driven timeline visualization - * Supports visual modes: compact, enriched, summary - */ - -/* ============================================ - CSS VARIABLES (with fallbacks) - ============================================ */ -.activity-timeline { - --timeline-bg: var(--bg-card, #1a1a1a); - --timeline-border: var(--border-color, #333); - --timeline-bg-secondary: var(--bg-secondary, #252525); - --timeline-bg-elevated: var(--bg-elevated, #2a2a2a); - --timeline-text-primary: var(--text-primary, #fff); - --timeline-text-secondary: var(--text-secondary, #888); - --timeline-text-dim: var(--text-dim, #666); - --timeline-accent: var(--accent-cyan, #4a9eff); - --timeline-status-new: var(--signal-new, #3b82f6); - --timeline-status-baseline: var(--signal-baseline, #6b7280); - --timeline-status-burst: var(--signal-burst, #f59e0b); - --timeline-status-flagged: var(--signal-emergency, #ef4444); - --timeline-status-gone: var(--text-dim, #666); -} - -/* ============================================ - TIMELINE CONTAINER - ============================================ */ -.activity-timeline { - background: var(--timeline-bg); - border: 1px solid var(--timeline-border); - border-radius: 6px; - font-family: 'JetBrains Mono', 'SF Mono', 'Consolas', monospace; - font-size: 11px; -} - -.activity-timeline.collapsed .activity-timeline-body { - display: none; -} - -.activity-timeline.collapsed .activity-timeline-header { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 10px; -} - -.activity-timeline.collapsed .activity-timeline-collapse-icon { - transform: rotate(-90deg); -} - -/* ============================================ - HEADER - ============================================ */ -.activity-timeline-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - cursor: pointer; - user-select: none; - transition: background 0.15s ease; -} - -.activity-timeline-header:hover { - background: rgba(255, 255, 255, 0.02); -} - -.activity-timeline-collapse-icon { - margin-right: 8px; - font-size: 10px; - transition: transform 0.2s ease; - color: var(--timeline-text-dim); -} - -.activity-timeline-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--timeline-text-secondary); -} - -.activity-timeline-header-stats { - display: flex; - gap: 12px; - font-size: 10px; - color: var(--timeline-text-dim); -} - -.activity-timeline-header-stat { - display: flex; - align-items: center; - gap: 4px; -} - -.activity-timeline-header-stat .stat-value { - color: var(--timeline-text-primary); - font-weight: 500; -} - -/* ============================================ - BODY - ============================================ */ -.activity-timeline-body { - padding: 0 12px 12px 12px; - border-top: 1px solid var(--timeline-border); -} - -/* ============================================ - CONTROLS - ============================================ */ -.activity-timeline-controls { - display: flex; - gap: 6px; - align-items: center; - padding: 8px 0; - flex-wrap: wrap; -} - -.activity-timeline-btn { - background: var(--timeline-bg-secondary); - border: 1px solid var(--timeline-border); - color: var(--timeline-text-secondary); - font-size: 9px; - padding: 4px 8px; - border-radius: 3px; - cursor: pointer; - transition: all 0.15s ease; - font-family: inherit; -} - -.activity-timeline-btn:hover { - background: var(--timeline-bg-elevated); - color: var(--timeline-text-primary); -} - -.activity-timeline-btn.active { - background: var(--timeline-accent); - color: #000; - border-color: var(--timeline-accent); -} - -.activity-timeline-window { - display: flex; - align-items: center; - gap: 4px; - font-size: 9px; - color: var(--timeline-text-dim); - margin-left: auto; -} - -.activity-timeline-window-select { - background: var(--timeline-bg-secondary); - border: 1px solid var(--timeline-border); - color: var(--timeline-text-primary); - font-size: 9px; - padding: 3px 6px; - border-radius: 3px; - font-family: inherit; -} - -/* ============================================ - TIME AXIS - ============================================ */ -.activity-timeline-axis { - display: flex; - justify-content: space-between; - padding: 0 50px 0 140px; - margin-bottom: 6px; - font-size: 9px; - color: var(--timeline-text-dim); -} - -.activity-timeline-axis-label { - position: relative; -} - -.activity-timeline-axis-label::before { - content: ''; - position: absolute; - top: -4px; - left: 50%; - width: 1px; - height: 4px; - background: var(--timeline-border); -} - -/* ============================================ - LANES CONTAINER - ============================================ */ -.activity-timeline-lanes { - display: flex; - flex-direction: column; - gap: 3px; - max-height: 180px; - overflow-y: auto; - margin-top: 6px; -} - -.activity-timeline-lanes::-webkit-scrollbar { - width: 6px; -} - -.activity-timeline-lanes::-webkit-scrollbar-track { - background: var(--timeline-bg-secondary); - border-radius: 3px; -} - -.activity-timeline-lanes::-webkit-scrollbar-thumb { - background: var(--timeline-border); - border-radius: 3px; -} - -.activity-timeline-lanes::-webkit-scrollbar-thumb:hover { - background: var(--timeline-text-dim); -} - -/* ============================================ - INDIVIDUAL LANE - ============================================ */ -.activity-timeline-lane { - display: flex; - align-items: stretch; - min-height: 32px; - background: var(--timeline-bg-secondary); - border-radius: 3px; - overflow: hidden; - cursor: pointer; - transition: background 0.15s ease; -} - -.activity-timeline-lane:hover { - background: var(--timeline-bg-elevated); -} - -.activity-timeline-lane.expanded { - min-height: auto; -} - -.activity-timeline-lane.baseline { - opacity: 0.5; -} - -.activity-timeline-lane.baseline:hover { - opacity: 0.8; -} - -/* Status indicator strip */ -.activity-timeline-status { - width: 4px; - min-width: 4px; - flex-shrink: 0; -} - -.activity-timeline-status[data-status="new"] { - background: var(--timeline-status-new); -} - -.activity-timeline-status[data-status="baseline"] { - background: var(--timeline-status-baseline); -} - -.activity-timeline-status[data-status="burst"] { - background: var(--timeline-status-burst); -} - -.activity-timeline-status[data-status="flagged"] { - background: var(--timeline-status-flagged); -} - -.activity-timeline-status[data-status="gone"] { - background: var(--timeline-status-gone); -} - -/* Label section */ -.activity-timeline-label { - width: 130px; - min-width: 130px; - padding: 6px 8px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 1px; - border-right: 1px solid var(--timeline-border); - overflow: hidden; -} - -.activity-timeline-id { - color: var(--timeline-text-primary); - font-size: 11px; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.2; -} - -.activity-timeline-name { - color: var(--timeline-text-dim); - font-size: 9px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.2; -} - -/* ============================================ - TRACK (where bars are drawn) - ============================================ */ -.activity-timeline-track { - flex: 1; - position: relative; - height: 100%; - min-height: 32px; - padding: 4px 8px; -} - -.activity-timeline-track-bg { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; -} - -/* ============================================ - SIGNAL BARS - ============================================ */ -.activity-timeline-bar { - position: absolute; - top: 50%; - transform: translateY(-50%); - height: 14px; - min-width: 2px; - border-radius: 2px; - transition: opacity 0.15s ease; -} - -/* Strength variants */ -.activity-timeline-bar[data-strength="1"] { height: 5px; } -.activity-timeline-bar[data-strength="2"] { height: 9px; } -.activity-timeline-bar[data-strength="3"] { height: 13px; } -.activity-timeline-bar[data-strength="4"] { height: 17px; } -.activity-timeline-bar[data-strength="5"] { height: 21px; } - -/* Status colors */ -.activity-timeline-bar[data-status="new"], -.activity-timeline-bar[data-status="repeated"] { - background: var(--timeline-status-new); - box-shadow: 0 0 4px rgba(59, 130, 246, 0.3); -} - -.activity-timeline-bar[data-status="baseline"] { - background: var(--timeline-status-baseline); -} - -.activity-timeline-bar[data-status="burst"] { - background: var(--timeline-status-burst); - box-shadow: 0 0 5px rgba(245, 158, 11, 0.4); -} - -.activity-timeline-bar[data-status="flagged"] { - background: var(--timeline-status-flagged); - box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); - animation: timeline-flagged-pulse 2s ease-in-out infinite; -} - -@keyframes timeline-flagged-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } -} - -.activity-timeline-lane:hover .activity-timeline-bar { - opacity: 0.9; -} - -/* ============================================ - EXPANDED VIEW (tick marks) - ============================================ */ -.activity-timeline-ticks { - display: none; - position: relative; - height: 24px; - margin-top: 4px; - border-top: 1px solid var(--timeline-border); - padding-top: 4px; -} - -.activity-timeline-lane.expanded .activity-timeline-ticks { - display: block; -} - -.activity-timeline-tick { - position: absolute; - bottom: 0; - width: 1px; - background: var(--timeline-accent); -} - -.activity-timeline-tick[data-strength="1"] { height: 4px; } -.activity-timeline-tick[data-strength="2"] { height: 8px; } -.activity-timeline-tick[data-strength="3"] { height: 12px; } -.activity-timeline-tick[data-strength="4"] { height: 16px; } -.activity-timeline-tick[data-strength="5"] { height: 20px; } - -/* ============================================ - STATS COLUMN - ============================================ */ -.activity-timeline-stats { - width: 45px; - min-width: 45px; - padding: 4px 6px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-end; - font-size: 9px; - color: var(--timeline-text-dim); - border-left: 1px solid var(--timeline-border); -} - -.activity-timeline-stat-count { - color: var(--timeline-text-primary); - font-weight: 500; -} - -.activity-timeline-stat-label { - font-size: 8px; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -/* ============================================ - ANNOTATIONS - ============================================ */ -.activity-timeline-annotations { - margin-top: 6px; - padding-top: 6px; - border-top: 1px solid var(--timeline-border); - max-height: 80px; - overflow-y: auto; -} - -.activity-timeline-annotation { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - font-size: 10px; - color: var(--timeline-text-secondary); - background: var(--timeline-bg-secondary); - border-radius: 3px; - margin-bottom: 4px; -} - -.activity-timeline-annotation-icon { - font-size: 10px; - width: 14px; - text-align: center; -} - -.activity-timeline-annotation[data-type="new"] { - border-left: 2px solid var(--timeline-status-new); -} - -.activity-timeline-annotation[data-type="burst"] { - border-left: 2px solid var(--timeline-status-burst); -} - -.activity-timeline-annotation[data-type="pattern"] { - border-left: 2px solid var(--timeline-accent); -} - -.activity-timeline-annotation[data-type="flagged"] { - border-left: 2px solid var(--timeline-status-flagged); - color: var(--timeline-status-flagged); -} - -.activity-timeline-annotation[data-type="gone"] { - border-left: 2px solid var(--timeline-status-gone); -} - -/* ============================================ - TOOLTIP - ============================================ */ -.activity-timeline-tooltip { - position: fixed; - z-index: 10000; - background: var(--timeline-bg-elevated); - border: 1px solid var(--timeline-border); - border-radius: 4px; - padding: 8px 10px; - font-size: 10px; - color: var(--timeline-text-primary); - pointer-events: none; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - max-width: 240px; - font-family: 'JetBrains Mono', monospace; -} - -.activity-timeline-tooltip-header { - font-weight: 600; - margin-bottom: 4px; - color: var(--timeline-accent); -} - -.activity-timeline-tooltip-row { - display: flex; - justify-content: space-between; - gap: 12px; - color: var(--timeline-text-secondary); - line-height: 1.5; -} - -.activity-timeline-tooltip-row span:last-child { - color: var(--timeline-text-primary); -} - -/* ============================================ - LEGEND - ============================================ */ -.activity-timeline-legend { - display: flex; - gap: 12px; - padding-top: 8px; - margin-top: 8px; - border-top: 1px solid var(--timeline-border); - font-size: 9px; - color: var(--timeline-text-dim); -} - -.activity-timeline-legend-item { - display: flex; - align-items: center; - gap: 4px; -} - -.activity-timeline-legend-dot { - width: 6px; - height: 6px; - border-radius: 2px; -} - -.activity-timeline-legend-dot.new { background: var(--timeline-status-new); } -.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); } -.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); } -.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); } - -/* ============================================ - EMPTY STATE - ============================================ */ -.activity-timeline-empty { - text-align: center; - padding: 24px 16px; - color: var(--timeline-text-dim); - font-size: 11px; -} - -.activity-timeline-empty-icon { - font-size: 20px; - margin-bottom: 8px; - opacity: 0.4; -} - -/* More indicator */ -.activity-timeline-more { - text-align: center; - padding: 8px; - font-size: 10px; - color: var(--timeline-text-dim); -} - -/* ============================================ - VISUAL MODE: COMPACT - ============================================ */ -.activity-timeline--compact .activity-timeline-lanes { - max-height: 140px; -} - -.activity-timeline--compact .activity-timeline-lane { - min-height: 26px; -} - -.activity-timeline--compact .activity-timeline-label { - width: 100px; - min-width: 100px; - padding: 4px 6px; -} - -.activity-timeline--compact .activity-timeline-id { - display: none; -} - -.activity-timeline--compact .activity-timeline-name { - font-size: 10px; - color: var(--timeline-text-secondary); -} - -.activity-timeline--compact .activity-timeline-track { - min-height: 26px; -} - -.activity-timeline--compact .activity-timeline-bar { - height: 10px !important; -} - -.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; } -.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; } -.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; } -.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; } -.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; } - -.activity-timeline--compact .activity-timeline-stats { - width: 30px; - min-width: 30px; -} - -.activity-timeline--compact .activity-timeline-stat-label { - display: none; -} - -.activity-timeline--compact .activity-timeline-legend { - display: none; -} - -.activity-timeline--compact .activity-timeline-axis { - padding-left: 110px; - padding-right: 40px; -} - -/* ============================================ - VISUAL MODE: SUMMARY - ============================================ */ -.activity-timeline--summary .activity-timeline-lanes { - max-height: 100px; -} - -.activity-timeline--summary .activity-timeline-lane { - min-height: 20px; -} - -.activity-timeline--summary .activity-timeline-label { - width: 80px; - min-width: 80px; - padding: 3px 6px; -} - -.activity-timeline--summary .activity-timeline-id, -.activity-timeline--summary .activity-timeline-name { - font-size: 9px; -} - -.activity-timeline--summary .activity-timeline-status { - width: 3px; - min-width: 3px; -} - -.activity-timeline--summary .activity-timeline-track { - min-height: 20px; -} - -.activity-timeline--summary .activity-timeline-bar { - height: 8px !important; - border-radius: 1px; -} - -.activity-timeline--summary .activity-timeline-stats { - display: none; -} - -.activity-timeline--summary .activity-timeline-ticks { - display: none !important; -} - -.activity-timeline--summary .activity-timeline-annotations { - display: none; -} - -.activity-timeline--summary .activity-timeline-legend { - display: none; -} - -.activity-timeline--summary .activity-timeline-axis { - padding-left: 90px; - padding-right: 10px; - font-size: 8px; -} - -/* ============================================ - BACKWARD COMPATIBILITY NOTE - The old signal-timeline.css is still loaded - for existing TSCM code that uses those classes. - New code should use activity-timeline classes. - ============================================ */ +/** + * Activity Timeline Component + * Reusable, configuration-driven timeline visualization + * Supports visual modes: compact, enriched, summary + */ + +/* ============================================ + CSS VARIABLES (with fallbacks) + ============================================ */ +.activity-timeline { + --timeline-bg: var(--bg-card, #1a1a1a); + --timeline-border: var(--border-color, #333); + --timeline-bg-secondary: var(--bg-secondary, #252525); + --timeline-bg-elevated: var(--bg-elevated, #2a2a2a); + --timeline-text-primary: var(--text-primary, #fff); + --timeline-text-secondary: var(--text-secondary, #888); + --timeline-text-dim: var(--text-dim, #666); + --timeline-accent: var(--accent-cyan, #4a9eff); + --timeline-status-new: var(--signal-new, #3b82f6); + --timeline-status-baseline: var(--signal-baseline, #6b7280); + --timeline-status-burst: var(--signal-burst, #f59e0b); + --timeline-status-flagged: var(--signal-emergency, #ef4444); + --timeline-status-gone: var(--text-dim, #666); +} + +/* ============================================ + TIMELINE CONTAINER + ============================================ */ +.activity-timeline { + background: var(--timeline-bg); + border: 1px solid var(--timeline-border); + border-radius: 6px; + font-family: var(--font-mono); + font-size: 11px; +} + +.activity-timeline.collapsed .activity-timeline-body { + display: none; +} + +.activity-timeline.collapsed .activity-timeline-header { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 10px; +} + +.activity-timeline.collapsed .activity-timeline-collapse-icon { + transform: rotate(-90deg); +} + +/* ============================================ + HEADER + ============================================ */ +.activity-timeline-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + user-select: none; + transition: background 0.15s ease; +} + +.activity-timeline-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.activity-timeline-collapse-icon { + margin-right: 8px; + font-size: 10px; + transition: transform 0.2s ease; + color: var(--timeline-text-dim); +} + +.activity-timeline-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--timeline-text-secondary); +} + +.activity-timeline-header-stats { + display: flex; + gap: 12px; + font-size: 10px; + color: var(--timeline-text-dim); +} + +.activity-timeline-header-stat { + display: flex; + align-items: center; + gap: 4px; +} + +.activity-timeline-header-stat .stat-value { + color: var(--timeline-text-primary); + font-weight: 500; +} + +/* ============================================ + BODY + ============================================ */ +.activity-timeline-body { + padding: 0 12px 12px 12px; + border-top: 1px solid var(--timeline-border); +} + +/* ============================================ + CONTROLS + ============================================ */ +.activity-timeline-controls { + display: flex; + gap: 6px; + align-items: center; + padding: 8px 0; + flex-wrap: wrap; +} + +.activity-timeline-btn { + background: var(--timeline-bg-secondary); + border: 1px solid var(--timeline-border); + color: var(--timeline-text-secondary); + font-size: 9px; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; +} + +.activity-timeline-btn:hover { + background: var(--timeline-bg-elevated); + color: var(--timeline-text-primary); +} + +.activity-timeline-btn.active { + background: var(--timeline-accent); + color: #000; + border-color: var(--timeline-accent); +} + +.activity-timeline-window { + display: flex; + align-items: center; + gap: 4px; + font-size: 9px; + color: var(--timeline-text-dim); + margin-left: auto; +} + +.activity-timeline-window-select { + background: var(--timeline-bg-secondary); + border: 1px solid var(--timeline-border); + color: var(--timeline-text-primary); + font-size: 9px; + padding: 3px 6px; + border-radius: 3px; + font-family: inherit; +} + +/* ============================================ + TIME AXIS + ============================================ */ +.activity-timeline-axis { + display: flex; + justify-content: space-between; + padding: 0 50px 0 140px; + margin-bottom: 6px; + font-size: 9px; + color: var(--timeline-text-dim); +} + +.activity-timeline-axis-label { + position: relative; +} + +.activity-timeline-axis-label::before { + content: ''; + position: absolute; + top: -4px; + left: 50%; + width: 1px; + height: 4px; + background: var(--timeline-border); +} + +/* ============================================ + LANES CONTAINER + ============================================ */ +.activity-timeline-lanes { + display: flex; + flex-direction: column; + gap: 3px; + max-height: 180px; + overflow-y: auto; + margin-top: 6px; +} + +.activity-timeline-lanes::-webkit-scrollbar { + width: 6px; +} + +.activity-timeline-lanes::-webkit-scrollbar-track { + background: var(--timeline-bg-secondary); + border-radius: 3px; +} + +.activity-timeline-lanes::-webkit-scrollbar-thumb { + background: var(--timeline-border); + border-radius: 3px; +} + +.activity-timeline-lanes::-webkit-scrollbar-thumb:hover { + background: var(--timeline-text-dim); +} + +/* ============================================ + INDIVIDUAL LANE + ============================================ */ +.activity-timeline-lane { + display: flex; + align-items: stretch; + min-height: 32px; + background: var(--timeline-bg-secondary); + border-radius: 3px; + overflow: hidden; + cursor: pointer; + transition: background 0.15s ease; +} + +.activity-timeline-lane:hover { + background: var(--timeline-bg-elevated); +} + +.activity-timeline-lane.expanded { + min-height: auto; +} + +.activity-timeline-lane.baseline { + opacity: 0.5; +} + +.activity-timeline-lane.baseline:hover { + opacity: 0.8; +} + +/* Status indicator strip */ +.activity-timeline-status { + width: 4px; + min-width: 4px; + flex-shrink: 0; +} + +.activity-timeline-status[data-status="new"] { + background: var(--timeline-status-new); +} + +.activity-timeline-status[data-status="baseline"] { + background: var(--timeline-status-baseline); +} + +.activity-timeline-status[data-status="burst"] { + background: var(--timeline-status-burst); +} + +.activity-timeline-status[data-status="flagged"] { + background: var(--timeline-status-flagged); +} + +.activity-timeline-status[data-status="gone"] { + background: var(--timeline-status-gone); +} + +/* Label section */ +.activity-timeline-label { + width: 130px; + min-width: 130px; + padding: 6px 8px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + border-right: 1px solid var(--timeline-border); + overflow: hidden; +} + +.activity-timeline-id { + color: var(--timeline-text-primary); + font-size: 11px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +.activity-timeline-name { + color: var(--timeline-text-dim); + font-size: 9px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +/* ============================================ + TRACK (where bars are drawn) + ============================================ */ +.activity-timeline-track { + flex: 1; + position: relative; + height: 100%; + min-height: 32px; + padding: 4px 8px; +} + +.activity-timeline-track-bg { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; +} + +/* ============================================ + SIGNAL BARS + ============================================ */ +.activity-timeline-bar { + position: absolute; + top: 50%; + transform: translateY(-50%); + height: 14px; + min-width: 2px; + border-radius: 2px; + transition: opacity 0.15s ease; +} + +/* Strength variants */ +.activity-timeline-bar[data-strength="1"] { height: 5px; } +.activity-timeline-bar[data-strength="2"] { height: 9px; } +.activity-timeline-bar[data-strength="3"] { height: 13px; } +.activity-timeline-bar[data-strength="4"] { height: 17px; } +.activity-timeline-bar[data-strength="5"] { height: 21px; } + +/* Status colors */ +.activity-timeline-bar[data-status="new"], +.activity-timeline-bar[data-status="repeated"] { + background: var(--timeline-status-new); + box-shadow: 0 0 4px rgba(59, 130, 246, 0.3); +} + +.activity-timeline-bar[data-status="baseline"] { + background: var(--timeline-status-baseline); +} + +.activity-timeline-bar[data-status="burst"] { + background: var(--timeline-status-burst); + box-shadow: 0 0 5px rgba(245, 158, 11, 0.4); +} + +.activity-timeline-bar[data-status="flagged"] { + background: var(--timeline-status-flagged); + box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); + animation: timeline-flagged-pulse 2s ease-in-out infinite; +} + +@keyframes timeline-flagged-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.activity-timeline-lane:hover .activity-timeline-bar { + opacity: 0.9; +} + +/* ============================================ + EXPANDED VIEW (tick marks) + ============================================ */ +.activity-timeline-ticks { + display: none; + position: relative; + height: 24px; + margin-top: 4px; + border-top: 1px solid var(--timeline-border); + padding-top: 4px; +} + +.activity-timeline-lane.expanded .activity-timeline-ticks { + display: block; +} + +.activity-timeline-tick { + position: absolute; + bottom: 0; + width: 1px; + background: var(--timeline-accent); +} + +.activity-timeline-tick[data-strength="1"] { height: 4px; } +.activity-timeline-tick[data-strength="2"] { height: 8px; } +.activity-timeline-tick[data-strength="3"] { height: 12px; } +.activity-timeline-tick[data-strength="4"] { height: 16px; } +.activity-timeline-tick[data-strength="5"] { height: 20px; } + +/* ============================================ + STATS COLUMN + ============================================ */ +.activity-timeline-stats { + width: 45px; + min-width: 45px; + padding: 4px 6px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + font-size: 9px; + color: var(--timeline-text-dim); + border-left: 1px solid var(--timeline-border); +} + +.activity-timeline-stat-count { + color: var(--timeline-text-primary); + font-weight: 500; +} + +.activity-timeline-stat-label { + font-size: 8px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* ============================================ + ANNOTATIONS + ============================================ */ +.activity-timeline-annotations { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--timeline-border); + max-height: 80px; + overflow-y: auto; +} + +.activity-timeline-annotation { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-size: 10px; + color: var(--timeline-text-secondary); + background: var(--timeline-bg-secondary); + border-radius: 3px; + margin-bottom: 4px; +} + +.activity-timeline-annotation-icon { + font-size: 10px; + width: 14px; + text-align: center; +} + +.activity-timeline-annotation[data-type="new"] { + border-left: 2px solid var(--timeline-status-new); +} + +.activity-timeline-annotation[data-type="burst"] { + border-left: 2px solid var(--timeline-status-burst); +} + +.activity-timeline-annotation[data-type="pattern"] { + border-left: 2px solid var(--timeline-accent); +} + +.activity-timeline-annotation[data-type="flagged"] { + border-left: 2px solid var(--timeline-status-flagged); + color: var(--timeline-status-flagged); +} + +.activity-timeline-annotation[data-type="gone"] { + border-left: 2px solid var(--timeline-status-gone); +} + +/* ============================================ + TOOLTIP + ============================================ */ +.activity-timeline-tooltip { + position: fixed; + z-index: 10000; + background: var(--timeline-bg-elevated); + border: 1px solid var(--timeline-border); + border-radius: 4px; + padding: 8px 10px; + font-size: 10px; + color: var(--timeline-text-primary); + pointer-events: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + max-width: 240px; + font-family: var(--font-mono); +} + +.activity-timeline-tooltip-header { + font-weight: 600; + margin-bottom: 4px; + color: var(--timeline-accent); +} + +.activity-timeline-tooltip-row { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--timeline-text-secondary); + line-height: 1.5; +} + +.activity-timeline-tooltip-row span:last-child { + color: var(--timeline-text-primary); +} + +/* ============================================ + LEGEND + ============================================ */ +.activity-timeline-legend { + display: flex; + gap: 12px; + padding-top: 8px; + margin-top: 8px; + border-top: 1px solid var(--timeline-border); + font-size: 9px; + color: var(--timeline-text-dim); +} + +.activity-timeline-legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.activity-timeline-legend-dot { + width: 6px; + height: 6px; + border-radius: 2px; +} + +.activity-timeline-legend-dot.new { background: var(--timeline-status-new); } +.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); } +.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); } +.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); } + +/* ============================================ + EMPTY STATE + ============================================ */ +.activity-timeline-empty { + text-align: center; + padding: 24px 16px; + color: var(--timeline-text-dim); + font-size: 11px; +} + +.activity-timeline-empty-icon { + font-size: 20px; + margin-bottom: 8px; + opacity: 0.4; +} + +/* More indicator */ +.activity-timeline-more { + text-align: center; + padding: 8px; + font-size: 10px; + color: var(--timeline-text-dim); +} + +/* ============================================ + VISUAL MODE: COMPACT + ============================================ */ +.activity-timeline--compact .activity-timeline-lanes { + max-height: 140px; +} + +.activity-timeline--compact .activity-timeline-lane { + min-height: 26px; +} + +.activity-timeline--compact .activity-timeline-label { + width: 100px; + min-width: 100px; + padding: 4px 6px; +} + +.activity-timeline--compact .activity-timeline-id { + display: none; +} + +.activity-timeline--compact .activity-timeline-name { + font-size: 10px; + color: var(--timeline-text-secondary); +} + +.activity-timeline--compact .activity-timeline-track { + min-height: 26px; +} + +.activity-timeline--compact .activity-timeline-bar { + height: 10px !important; +} + +.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; } +.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; } +.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; } +.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; } +.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; } + +.activity-timeline--compact .activity-timeline-stats { + width: 30px; + min-width: 30px; +} + +.activity-timeline--compact .activity-timeline-stat-label { + display: none; +} + +.activity-timeline--compact .activity-timeline-legend { + display: none; +} + +.activity-timeline--compact .activity-timeline-axis { + padding-left: 110px; + padding-right: 40px; +} + +/* ============================================ + VISUAL MODE: SUMMARY + ============================================ */ +.activity-timeline--summary .activity-timeline-lanes { + max-height: 100px; +} + +.activity-timeline--summary .activity-timeline-lane { + min-height: 20px; +} + +.activity-timeline--summary .activity-timeline-label { + width: 80px; + min-width: 80px; + padding: 3px 6px; +} + +.activity-timeline--summary .activity-timeline-id, +.activity-timeline--summary .activity-timeline-name { + font-size: 9px; +} + +.activity-timeline--summary .activity-timeline-status { + width: 3px; + min-width: 3px; +} + +.activity-timeline--summary .activity-timeline-track { + min-height: 20px; +} + +.activity-timeline--summary .activity-timeline-bar { + height: 8px !important; + border-radius: 1px; +} + +.activity-timeline--summary .activity-timeline-stats { + display: none; +} + +.activity-timeline--summary .activity-timeline-ticks { + display: none !important; +} + +.activity-timeline--summary .activity-timeline-annotations { + display: none; +} + +.activity-timeline--summary .activity-timeline-legend { + display: none; +} + +.activity-timeline--summary .activity-timeline-axis { + padding-left: 90px; + padding-right: 10px; + font-size: 8px; +} + +/* ============================================ + BACKWARD COMPATIBILITY NOTE + The old signal-timeline.css is still loaded + for existing TSCM code that uses those classes. + New code should use activity-timeline classes. + ============================================ */ diff --git a/static/css/components/device-cards.css b/static/css/components/device-cards.css index 26bdf07..645d258 100644 --- a/static/css/components/device-cards.css +++ b/static/css/components/device-cards.css @@ -1,879 +1,879 @@ -/** - * Device Cards Component CSS - * Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines - */ - -/* ============================================ - CSS VARIABLES - ============================================ */ -:root { - /* Protocol colors */ - --proto-ble: #3b82f6; - --proto-ble-bg: rgba(59, 130, 246, 0.15); - --proto-classic: #8b5cf6; - --proto-classic-bg: rgba(139, 92, 246, 0.15); - - /* Range band colors */ - --range-very-close: #ef4444; - --range-close: #f97316; - --range-nearby: #eab308; - --range-far: #6b7280; - --range-unknown: #374151; - - /* Heuristic badge colors */ - --heuristic-new: #3b82f6; - --heuristic-persistent: #22c55e; - --heuristic-beacon: #f59e0b; - --heuristic-strong: #ef4444; - --heuristic-random: #6b7280; -} - -/* ============================================ - DEVICE CARD BASE - ============================================ */ -.device-card { - cursor: pointer; - transition: all 0.15s ease; -} - -.device-card:hover { - border-color: var(--accent-cyan, #00d4ff); - box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2); -} - -.device-card:active { - transform: scale(0.995); -} - -/* ============================================ - DEVICE IDENTITY - ============================================ */ -.device-identity { - margin-bottom: 10px; -} - -.device-name { - font-family: 'Inter', -apple-system, sans-serif; - font-size: 14px; - font-weight: 600; - color: var(--text-primary, #e0e0e0); - margin-bottom: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.device-address { - display: flex; - align-items: center; - gap: 6px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; -} - -.device-address .address-value { - color: var(--accent-cyan, #00d4ff); -} - -.device-address .address-type { - color: var(--text-dim, #666); - font-size: 10px; -} - -/* ============================================ - PROTOCOL BADGES - ============================================ */ -.signal-proto-badge.device-protocol { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 2px 6px; - border-radius: 3px; - border: 1px solid; -} - -/* ============================================ - HEURISTIC BADGES - ============================================ */ -.device-heuristic-badge { - display: inline-flex; - align-items: center; - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; - padding: 2px 6px; - border-radius: 3px; - background: color-mix(in srgb, var(--badge-color) 15%, transparent); - color: var(--badge-color); - border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent); -} - -.device-heuristic-badge.new { - --badge-color: var(--heuristic-new); - animation: heuristicPulse 2s ease-in-out infinite; -} - -.device-heuristic-badge.persistent { - --badge-color: var(--heuristic-persistent); -} - -.device-heuristic-badge.beacon_like { - --badge-color: var(--heuristic-beacon); -} - -.device-heuristic-badge.strong_stable { - --badge-color: var(--heuristic-strong); -} - -.device-heuristic-badge.random_address { - --badge-color: var(--heuristic-random); -} - -@keyframes heuristicPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } -} - -/* ============================================ - SIGNAL ROW & RSSI DISPLAY - ============================================ */ -.device-signal-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px; - background: var(--bg-secondary, #1a1a1a); - border-radius: 6px; - margin-bottom: 8px; -} - -.rssi-display { - display: flex; - align-items: center; - gap: 10px; -} - -.rssi-current { - font-family: 'JetBrains Mono', monospace; - font-size: 16px; - font-weight: 600; - color: var(--text-primary, #e0e0e0); - min-width: 70px; -} - -/* ============================================ - RSSI SPARKLINE - ============================================ */ -.rssi-sparkline, -.rssi-sparkline-svg { - display: inline-block; - vertical-align: middle; -} - -.rssi-sparkline-empty { - opacity: 0.5; -} - -.rssi-sparkline-wrapper { - display: flex; - align-items: center; - gap: 8px; -} - -.rssi-value { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - font-weight: 500; -} - -.rssi-current-value { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 500; - margin-left: 6px; -} - -.sparkline-dot { - animation: sparklinePulse 1.5s ease-in-out infinite; -} - -@keyframes sparklinePulse { - 0%, 100% { r: 2; opacity: 1; } - 50% { r: 3; opacity: 0.8; } -} - -/* ============================================ - RANGE BAND INDICATOR - ============================================ */ -.device-range-band { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - background: color-mix(in srgb, var(--range-color) 15%, transparent); - border-radius: 4px; - border-left: 3px solid var(--range-color); -} - -.device-range-band .range-label { - font-family: 'Inter', sans-serif; - font-size: 11px; - font-weight: 600; - color: var(--range-color); - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.device-range-band .range-estimate { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-dim, #666); -} - -.device-range-band .range-confidence { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - color: var(--text-dim, #666); - padding: 1px 4px; - background: rgba(0, 0, 0, 0.2); - border-radius: 3px; -} - -/* ============================================ - MANUFACTURER INFO - ============================================ */ -.device-manufacturer { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: var(--text-secondary, #888); - margin-bottom: 6px; -} - -.device-manufacturer .mfr-icon { - font-size: 12px; - opacity: 0.7; -} - -.device-manufacturer .mfr-name { - font-family: 'Inter', sans-serif; -} - -/* ============================================ - META ROW - ============================================ */ -.device-meta-row { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 10px; - color: var(--text-dim, #666); -} - -.device-seen-count { - display: flex; - align-items: center; - gap: 3px; - font-family: 'JetBrains Mono', monospace; -} - -.device-seen-count .seen-icon { - font-size: 10px; - opacity: 0.7; -} - -.device-timestamp { - font-family: 'JetBrains Mono', monospace; -} - -/* ============================================ - SERVICE UUIDS - ============================================ */ -.device-uuids { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.device-uuid { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - padding: 2px 6px; - background: var(--bg-tertiary, #1a1a1a); - border-radius: 3px; - color: var(--text-secondary, #888); - border: 1px solid var(--border-color, #333); -} - -/* ============================================ - HEURISTICS DETAIL VIEW - ============================================ */ -.device-heuristics-detail { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: 6px; -} - -.heuristic-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 8px; - background: var(--bg-tertiary, #1a1a1a); - border-radius: 4px; - border: 1px solid var(--border-color, #333); -} - -.heuristic-item.active { - background: rgba(34, 197, 94, 0.1); - border-color: rgba(34, 197, 94, 0.3); -} - -.heuristic-item .heuristic-name { - font-size: 10px; - text-transform: capitalize; - color: var(--text-secondary, #888); -} - -.heuristic-item .heuristic-status { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; -} - -.heuristic-item.active .heuristic-status { - color: var(--accent-green, #22c55e); -} - -.heuristic-item:not(.active) .heuristic-status { - color: var(--text-dim, #666); -} - -/* ============================================ - MESSAGE CARDS - ============================================ */ -.message-card { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 12px 14px; - background: var(--message-bg); - border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent); - border-radius: 8px; - margin-bottom: 12px; - animation: messageSlideIn 0.25s ease; - position: relative; -} - -@keyframes messageSlideIn { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.message-card.message-card-hiding { - opacity: 0; - transform: translateY(-8px); - transition: all 0.2s ease; -} - -.message-card::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 3px; - background: var(--message-color); - border-radius: 8px 0 0 8px; -} - -.message-card-icon { - flex-shrink: 0; - width: 20px; - height: 20px; - color: var(--message-color); -} - -.message-card-icon svg { - width: 100%; - height: 100%; -} - -.message-card-icon svg.animate-spin { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -.message-card-content { - flex: 1; - min-width: 0; -} - -.message-card-title { - font-family: 'Inter', sans-serif; - font-size: 13px; - font-weight: 600; - color: var(--text-primary, #e0e0e0); - margin-bottom: 2px; -} - -.message-card-text { - font-size: 12px; - color: var(--text-secondary, #888); - line-height: 1.4; -} - -.message-card-details { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-dim, #666); - margin-top: 4px; -} - -.message-card-dismiss { - flex-shrink: 0; - width: 20px; - height: 20px; - padding: 0; - background: none; - border: none; - color: var(--text-dim, #666); - cursor: pointer; - opacity: 0.5; - transition: opacity 0.15s, color 0.15s; -} - -.message-card-dismiss:hover { - opacity: 1; - color: var(--text-primary, #e0e0e0); -} - -.message-card-dismiss svg { - width: 100%; - height: 100%; -} - -.message-card-actions { - display: flex; - gap: 8px; - margin-top: 10px; -} - -.message-action-btn { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 5px 10px; - border-radius: 4px; - border: 1px solid var(--border-color, #333); - background: var(--bg-secondary, #1a1a1a); - color: var(--text-secondary, #888); - cursor: pointer; - transition: all 0.15s; -} - -.message-action-btn:hover { - background: var(--bg-tertiary, #252525); - border-color: var(--border-light, #444); - color: var(--text-primary, #e0e0e0); -} - -.message-action-btn.primary { - background: color-mix(in srgb, var(--message-color) 20%, transparent); - border-color: color-mix(in srgb, var(--message-color) 40%, transparent); - color: var(--message-color); -} - -.message-action-btn.primary:hover { - background: color-mix(in srgb, var(--message-color) 30%, transparent); -} - -/* ============================================ - DEVICE FILTER BAR - ============================================ */ -.device-filter-bar { - flex-wrap: wrap; -} - -.device-filter-bar .signal-filter-btn .filter-dot { - width: 6px; - height: 6px; - border-radius: 50%; -} - -/* ============================================ - RESPONSIVE ADJUSTMENTS - ============================================ */ -@media (max-width: 600px) { - .device-signal-row { - flex-direction: column; - align-items: stretch; - gap: 8px; - } - - .rssi-display { - justify-content: center; - } - - .device-range-band { - justify-content: center; - } - - .device-heuristics-detail { - grid-template-columns: 1fr; - } - - .message-card { - padding: 10px 12px; - } - - .message-card-title { - font-size: 12px; - } - - .message-card-text { - font-size: 11px; - } -} - -/* ============================================ - BLUETOOTH DEVICE LIST CONTAINER - ============================================ */ -#btDeviceListContent { - display: block !important; - padding: 10px !important; - overflow-y: auto !important; - overflow-x: hidden !important; -} - -/* Pure inline-styled cards - ensure no interference */ -#btDeviceListContent > div[data-bt-device-id] { - display: block !important; - visibility: visible !important; - opacity: 1 !important; - height: auto !important; - min-height: auto !important; - overflow: visible !important; -} - -/* Legacy card support */ -#btDeviceListContent .device-card, -#btDeviceListContent .signal-card { - margin: 0 0 10px 0; - height: auto !important; - min-height: auto !important; - overflow: visible !important; -} - -/* Ensure card body is visible */ -.device-card .signal-card-body, -.signal-card .signal-card-body { - display: flex !important; - flex-direction: column !important; - gap: 8px !important; - visibility: visible !important; - opacity: 1 !important; - height: auto !important; - overflow: visible !important; -} - -.device-card .device-identity, -.signal-card .device-identity { - display: block !important; - visibility: visible !important; -} - -.device-card .device-signal-row, -.signal-card .device-signal-row { - display: flex !important; - visibility: visible !important; -} - -.device-card .device-meta-row, -.signal-card .device-meta-row { - display: flex !important; - visibility: visible !important; -} - -/* ============================================ - ENHANCED MODAL STYLES - ============================================ */ -.signal-details-modal-header .modal-header-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.signal-details-modal-subtitle { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-dim, #666); -} - -.signal-details-modal-footer { - display: flex; - gap: 8px; - justify-content: flex-end; -} - -.signal-details-copy-addr-btn { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - padding: 8px 16px; - background: var(--bg-secondary, #252525); - border: 1px solid var(--border-color, #333); - border-radius: 4px; - color: var(--text-secondary, #888); - cursor: pointer; - transition: all 0.15s ease; -} - -.signal-details-copy-addr-btn:hover { - background: var(--bg-tertiary, #1a1a1a); - color: var(--text-primary, #e0e0e0); -} - -/* Modal Header Section */ -.modal-device-header { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 16px; - margin-bottom: 16px; - border-bottom: 1px solid var(--border-color, #333); -} - -.modal-badges { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -/* Modal Sections */ -.modal-section { - margin-bottom: 20px; -} - -.modal-section:last-child { - margin-bottom: 0; -} - -.modal-section-title { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-dim, #666); - margin-bottom: 12px; -} - -/* Signal Display */ -.modal-signal-display { - display: flex; - align-items: center; - gap: 24px; - padding: 16px; - background: var(--bg-secondary, #1a1a1a); - border-radius: 8px; - margin-bottom: 12px; -} - -.modal-rssi-large { - font-family: 'JetBrains Mono', monospace; - font-size: 36px; - font-weight: 700; - color: var(--accent-cyan, #00d4ff); - line-height: 1; -} - -.modal-rssi-large .rssi-unit { - font-size: 14px; - font-weight: 400; - color: var(--text-dim, #666); - margin-left: 4px; -} - -.modal-sparkline { - flex: 1; - display: flex; - justify-content: flex-end; -} - -/* Signal Stats Grid */ -.modal-signal-stats { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; -} - -.modal-signal-stats .stat-item { - display: flex; - flex-direction: column; - align-items: center; - padding: 10px; - background: var(--bg-secondary, #1a1a1a); - border-radius: 6px; - text-align: center; -} - -.modal-signal-stats .stat-label { - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-dim, #666); - margin-bottom: 4px; -} - -.modal-signal-stats .stat-value { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--text-primary, #e0e0e0); -} - -/* Info Grid */ -.modal-info-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 8px; -} - -.modal-info-grid .info-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 12px; - background: var(--bg-secondary, #1a1a1a); - border-radius: 6px; -} - -.modal-info-grid .info-label { - font-size: 11px; - color: var(--text-dim, #666); -} - -.modal-info-grid .info-value { - font-size: 12px; - font-weight: 500; - color: var(--text-primary, #e0e0e0); -} - -.modal-info-grid .info-value.mono { - font-family: 'JetBrains Mono', monospace; - color: var(--accent-cyan, #00d4ff); -} - -/* UUID List */ -.modal-uuid-list { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.modal-uuid { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - padding: 4px 8px; - background: var(--bg-secondary, #1a1a1a); - border: 1px solid var(--border-color, #333); - border-radius: 4px; - color: var(--text-secondary, #888); -} - -/* Heuristics Grid */ -.modal-heuristics-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 8px; -} - -.heuristic-check { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - background: var(--bg-secondary, #1a1a1a); - border-radius: 6px; - border: 1px solid var(--border-color, #333); -} - -.heuristic-check.active { - background: rgba(34, 197, 94, 0.1); - border-color: rgba(34, 197, 94, 0.3); -} - -.heuristic-indicator { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 600; - color: var(--text-dim, #666); -} - -.heuristic-check.active .heuristic-indicator { - color: var(--accent-green, #22c55e); -} - -.heuristic-label { - font-size: 11px; - text-transform: capitalize; - color: var(--text-secondary, #888); -} - -/* ============================================ - RESPONSIVE MODAL - ============================================ */ -@media (max-width: 600px) { - .modal-signal-stats { - grid-template-columns: repeat(2, 1fr); - } - - .modal-info-grid { - grid-template-columns: 1fr; - } - - .modal-signal-display { - flex-direction: column; - align-items: flex-start; - gap: 16px; - } - - .modal-sparkline { - width: 100%; - justify-content: center; - } - - .modal-device-header { - flex-direction: column; - align-items: flex-start; - gap: 12px; - } -} - -/* ============================================ - DARK MODE OVERRIDES (if needed) - ============================================ */ -@media (prefers-color-scheme: dark) { - .device-card { - --bg-secondary: #1a1a1a; - --bg-tertiary: #141414; - } -} +/** + * Device Cards Component CSS + * Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines + */ + +/* ============================================ + CSS VARIABLES + ============================================ */ +:root { + /* Protocol colors */ + --proto-ble: #3b82f6; + --proto-ble-bg: rgba(59, 130, 246, 0.15); + --proto-classic: #8b5cf6; + --proto-classic-bg: rgba(139, 92, 246, 0.15); + + /* Range band colors */ + --range-very-close: #ef4444; + --range-close: #f97316; + --range-nearby: #eab308; + --range-far: #6b7280; + --range-unknown: #374151; + + /* Heuristic badge colors */ + --heuristic-new: #3b82f6; + --heuristic-persistent: #22c55e; + --heuristic-beacon: #f59e0b; + --heuristic-strong: #ef4444; + --heuristic-random: #6b7280; +} + +/* ============================================ + DEVICE CARD BASE + ============================================ */ +.device-card { + cursor: pointer; + transition: all 0.15s ease; +} + +.device-card:hover { + border-color: var(--accent-cyan, #00d4ff); + box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2); +} + +.device-card:active { + transform: scale(0.995); +} + +/* ============================================ + DEVICE IDENTITY + ============================================ */ +.device-identity { + margin-bottom: 10px; +} + +.device-name { + font-family: 'Inter', -apple-system, sans-serif; + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.device-address { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 11px; +} + +.device-address .address-value { + color: var(--accent-cyan, #00d4ff); +} + +.device-address .address-type { + color: var(--text-dim, #666); + font-size: 10px; +} + +/* ============================================ + PROTOCOL BADGES + ============================================ */ +.signal-proto-badge.device-protocol { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 6px; + border-radius: 3px; + border: 1px solid; +} + +/* ============================================ + HEURISTIC BADGES + ============================================ */ +.device-heuristic-badge { + display: inline-flex; + align-items: center; + font-family: var(--font-mono); + font-size: 9px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 2px 6px; + border-radius: 3px; + background: color-mix(in srgb, var(--badge-color) 15%, transparent); + color: var(--badge-color); + border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent); +} + +.device-heuristic-badge.new { + --badge-color: var(--heuristic-new); + animation: heuristicPulse 2s ease-in-out infinite; +} + +.device-heuristic-badge.persistent { + --badge-color: var(--heuristic-persistent); +} + +.device-heuristic-badge.beacon_like { + --badge-color: var(--heuristic-beacon); +} + +.device-heuristic-badge.strong_stable { + --badge-color: var(--heuristic-strong); +} + +.device-heuristic-badge.random_address { + --badge-color: var(--heuristic-random); +} + +@keyframes heuristicPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* ============================================ + SIGNAL ROW & RSSI DISPLAY + ============================================ */ +.device-signal-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 6px; + margin-bottom: 8px; +} + +.rssi-display { + display: flex; + align-items: center; + gap: 10px; +} + +.rssi-current { + font-family: var(--font-mono); + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + min-width: 70px; +} + +/* ============================================ + RSSI SPARKLINE + ============================================ */ +.rssi-sparkline, +.rssi-sparkline-svg { + display: inline-block; + vertical-align: middle; +} + +.rssi-sparkline-empty { + opacity: 0.5; +} + +.rssi-sparkline-wrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.rssi-value { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; +} + +.rssi-current-value { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + margin-left: 6px; +} + +.sparkline-dot { + animation: sparklinePulse 1.5s ease-in-out infinite; +} + +@keyframes sparklinePulse { + 0%, 100% { r: 2; opacity: 1; } + 50% { r: 3; opacity: 0.8; } +} + +/* ============================================ + RANGE BAND INDICATOR + ============================================ */ +.device-range-band { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: color-mix(in srgb, var(--range-color) 15%, transparent); + border-radius: 4px; + border-left: 3px solid var(--range-color); +} + +.device-range-band .range-label { + font-family: 'Inter', sans-serif; + font-size: 11px; + font-weight: 600; + color: var(--range-color); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.device-range-band .range-estimate { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim, #666); +} + +.device-range-band .range-confidence { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim, #666); + padding: 1px 4px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +/* ============================================ + MANUFACTURER INFO + ============================================ */ +.device-manufacturer { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-secondary, #888); + margin-bottom: 6px; +} + +.device-manufacturer .mfr-icon { + font-size: 12px; + opacity: 0.7; +} + +.device-manufacturer .mfr-name { + font-family: 'Inter', sans-serif; +} + +/* ============================================ + META ROW + ============================================ */ +.device-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 10px; + color: var(--text-dim, #666); +} + +.device-seen-count { + display: flex; + align-items: center; + gap: 3px; + font-family: var(--font-mono); +} + +.device-seen-count .seen-icon { + font-size: 10px; + opacity: 0.7; +} + +.device-timestamp { + font-family: var(--font-mono); +} + +/* ============================================ + SERVICE UUIDS + ============================================ */ +.device-uuids { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.device-uuid { + font-family: var(--font-mono); + font-size: 9px; + padding: 2px 6px; + background: var(--bg-tertiary, #1a1a1a); + border-radius: 3px; + color: var(--text-secondary, #888); + border: 1px solid var(--border-color, #333); +} + +/* ============================================ + HEURISTICS DETAIL VIEW + ============================================ */ +.device-heuristics-detail { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 6px; +} + +.heuristic-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + background: var(--bg-tertiary, #1a1a1a); + border-radius: 4px; + border: 1px solid var(--border-color, #333); +} + +.heuristic-item.active { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); +} + +.heuristic-item .heuristic-name { + font-size: 10px; + text-transform: capitalize; + color: var(--text-secondary, #888); +} + +.heuristic-item .heuristic-status { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; +} + +.heuristic-item.active .heuristic-status { + color: var(--accent-green, #22c55e); +} + +.heuristic-item:not(.active) .heuristic-status { + color: var(--text-dim, #666); +} + +/* ============================================ + MESSAGE CARDS + ============================================ */ +.message-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + background: var(--message-bg); + border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent); + border-radius: 8px; + margin-bottom: 12px; + animation: messageSlideIn 0.25s ease; + position: relative; +} + +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message-card.message-card-hiding { + opacity: 0; + transform: translateY(-8px); + transition: all 0.2s ease; +} + +.message-card::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--message-color); + border-radius: 8px 0 0 8px; +} + +.message-card-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + color: var(--message-color); +} + +.message-card-icon svg { + width: 100%; + height: 100%; +} + +.message-card-icon svg.animate-spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.message-card-content { + flex: 1; + min-width: 0; +} + +.message-card-title { + font-family: 'Inter', sans-serif; + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin-bottom: 2px; +} + +.message-card-text { + font-size: 12px; + color: var(--text-secondary, #888); + line-height: 1.4; +} + +.message-card-details { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim, #666); + margin-top: 4px; +} + +.message-card-dismiss { + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + background: none; + border: none; + color: var(--text-dim, #666); + cursor: pointer; + opacity: 0.5; + transition: opacity 0.15s, color 0.15s; +} + +.message-card-dismiss:hover { + opacity: 1; + color: var(--text-primary, #e0e0e0); +} + +.message-card-dismiss svg { + width: 100%; + height: 100%; +} + +.message-card-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.message-action-btn { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 5px 10px; + border-radius: 4px; + border: 1px solid var(--border-color, #333); + background: var(--bg-secondary, #1a1a1a); + color: var(--text-secondary, #888); + cursor: pointer; + transition: all 0.15s; +} + +.message-action-btn:hover { + background: var(--bg-tertiary, #252525); + border-color: var(--border-light, #444); + color: var(--text-primary, #e0e0e0); +} + +.message-action-btn.primary { + background: color-mix(in srgb, var(--message-color) 20%, transparent); + border-color: color-mix(in srgb, var(--message-color) 40%, transparent); + color: var(--message-color); +} + +.message-action-btn.primary:hover { + background: color-mix(in srgb, var(--message-color) 30%, transparent); +} + +/* ============================================ + DEVICE FILTER BAR + ============================================ */ +.device-filter-bar { + flex-wrap: wrap; +} + +.device-filter-bar .signal-filter-btn .filter-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +/* ============================================ + RESPONSIVE ADJUSTMENTS + ============================================ */ +@media (max-width: 600px) { + .device-signal-row { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .rssi-display { + justify-content: center; + } + + .device-range-band { + justify-content: center; + } + + .device-heuristics-detail { + grid-template-columns: 1fr; + } + + .message-card { + padding: 10px 12px; + } + + .message-card-title { + font-size: 12px; + } + + .message-card-text { + font-size: 11px; + } +} + +/* ============================================ + BLUETOOTH DEVICE LIST CONTAINER + ============================================ */ +#btDeviceListContent { + display: block !important; + padding: 10px !important; + overflow-y: auto !important; + overflow-x: hidden !important; +} + +/* Pure inline-styled cards - ensure no interference */ +#btDeviceListContent > div[data-bt-device-id] { + display: block !important; + visibility: visible !important; + opacity: 1 !important; + height: auto !important; + min-height: auto !important; + overflow: visible !important; +} + +/* Legacy card support */ +#btDeviceListContent .device-card, +#btDeviceListContent .signal-card { + margin: 0 0 10px 0; + height: auto !important; + min-height: auto !important; + overflow: visible !important; +} + +/* Ensure card body is visible */ +.device-card .signal-card-body, +.signal-card .signal-card-body { + display: flex !important; + flex-direction: column !important; + gap: 8px !important; + visibility: visible !important; + opacity: 1 !important; + height: auto !important; + overflow: visible !important; +} + +.device-card .device-identity, +.signal-card .device-identity { + display: block !important; + visibility: visible !important; +} + +.device-card .device-signal-row, +.signal-card .device-signal-row { + display: flex !important; + visibility: visible !important; +} + +.device-card .device-meta-row, +.signal-card .device-meta-row { + display: flex !important; + visibility: visible !important; +} + +/* ============================================ + ENHANCED MODAL STYLES + ============================================ */ +.signal-details-modal-header .modal-header-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.signal-details-modal-subtitle { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-dim, #666); +} + +.signal-details-modal-footer { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.signal-details-copy-addr-btn { + font-family: var(--font-mono); + font-size: 11px; + padding: 8px 16px; + background: var(--bg-secondary, #252525); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + color: var(--text-secondary, #888); + cursor: pointer; + transition: all 0.15s ease; +} + +.signal-details-copy-addr-btn:hover { + background: var(--bg-tertiary, #1a1a1a); + color: var(--text-primary, #e0e0e0); +} + +/* Modal Header Section */ +.modal-device-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border-color, #333); +} + +.modal-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* Modal Sections */ +.modal-section { + margin-bottom: 20px; +} + +.modal-section:last-child { + margin-bottom: 0; +} + +.modal-section-title { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim, #666); + margin-bottom: 12px; +} + +/* Signal Display */ +.modal-signal-display { + display: flex; + align-items: center; + gap: 24px; + padding: 16px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 8px; + margin-bottom: 12px; +} + +.modal-rssi-large { + font-family: var(--font-mono); + font-size: 36px; + font-weight: 700; + color: var(--accent-cyan, #00d4ff); + line-height: 1; +} + +.modal-rssi-large .rssi-unit { + font-size: 14px; + font-weight: 400; + color: var(--text-dim, #666); + margin-left: 4px; +} + +.modal-sparkline { + flex: 1; + display: flex; + justify-content: flex-end; +} + +/* Signal Stats Grid */ +.modal-signal-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.modal-signal-stats .stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 6px; + text-align: center; +} + +.modal-signal-stats .stat-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim, #666); + margin-bottom: 4px; +} + +.modal-signal-stats .stat-value { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); +} + +/* Info Grid */ +.modal-info-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.modal-info-grid .info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 6px; +} + +.modal-info-grid .info-label { + font-size: 11px; + color: var(--text-dim, #666); +} + +.modal-info-grid .info-value { + font-size: 12px; + font-weight: 500; + color: var(--text-primary, #e0e0e0); +} + +.modal-info-grid .info-value.mono { + font-family: var(--font-mono); + color: var(--accent-cyan, #00d4ff); +} + +/* UUID List */ +.modal-uuid-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.modal-uuid { + font-family: var(--font-mono); + font-size: 10px; + padding: 4px 8px; + background: var(--bg-secondary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + color: var(--text-secondary, #888); +} + +/* Heuristics Grid */ +.modal-heuristics-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; +} + +.heuristic-check { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 6px; + border: 1px solid var(--border-color, #333); +} + +.heuristic-check.active { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); +} + +.heuristic-indicator { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--text-dim, #666); +} + +.heuristic-check.active .heuristic-indicator { + color: var(--accent-green, #22c55e); +} + +.heuristic-label { + font-size: 11px; + text-transform: capitalize; + color: var(--text-secondary, #888); +} + +/* ============================================ + RESPONSIVE MODAL + ============================================ */ +@media (max-width: 600px) { + .modal-signal-stats { + grid-template-columns: repeat(2, 1fr); + } + + .modal-info-grid { + grid-template-columns: 1fr; + } + + .modal-signal-display { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .modal-sparkline { + width: 100%; + justify-content: center; + } + + .modal-device-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } +} + +/* ============================================ + DARK MODE OVERRIDES (if needed) + ============================================ */ +@media (prefers-color-scheme: dark) { + .device-card { + --bg-secondary: #1a1a1a; + --bg-tertiary: #141414; + } +} diff --git a/static/css/components/function-strip.css b/static/css/components/function-strip.css index 492be77..8ff5c65 100644 --- a/static/css/components/function-strip.css +++ b/static/css/components/function-strip.css @@ -1,371 +1,371 @@ -/* Function Strip (Action Bar) - Shared across modes - * Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc. - */ - -.function-strip { - background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 8px 12px; - margin-bottom: 10px; - overflow: visible; - min-height: 44px; -} - -.function-strip-inner { - display: flex; - align-items: center; - gap: 8px; - min-width: max-content; -} - -/* Stats */ -.function-strip .strip-stat { - display: flex; - flex-direction: column; - align-items: center; - padding: 6px 10px; - background: rgba(74, 158, 255, 0.05); - border: 1px solid rgba(74, 158, 255, 0.15); - border-radius: 4px; - min-width: 55px; -} - -.function-strip .strip-stat:hover { - background: rgba(74, 158, 255, 0.1); - border-color: rgba(74, 158, 255, 0.3); -} - -.function-strip .strip-value { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 600; - color: var(--accent-cyan); - line-height: 1.2; -} - -.function-strip .strip-label { - font-size: 8px; - font-weight: 600; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-top: 1px; -} - -.function-strip .strip-divider { - width: 1px; - height: 28px; - background: var(--border-color); - margin: 0 4px; -} - -/* Signal stat coloring */ -.function-strip .signal-stat.good .strip-value { color: var(--accent-green); } -.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); } -.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); } - -/* Controls */ -.function-strip .strip-control { - display: flex; - align-items: center; - gap: 4px; -} - -.function-strip .strip-select { - background: rgba(0,0,0,0.3); - border: 1px solid var(--border-color); - color: var(--text-primary); - padding: 4px 8px; - border-radius: 4px; - font-size: 10px; - cursor: pointer; -} - -.function-strip .strip-select:hover { - border-color: var(--accent-cyan); -} - -.function-strip .strip-select:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.function-strip .strip-input-label { - font-size: 9px; - color: var(--text-muted); - font-weight: 600; -} - -.function-strip .strip-input { - background: rgba(0,0,0,0.3); - border: 1px solid var(--border-color); - color: var(--text-primary); - padding: 4px 6px; - border-radius: 4px; - font-size: 10px; - width: 50px; - text-align: center; -} - -.function-strip .strip-input:hover, -.function-strip .strip-input:focus { - border-color: var(--accent-cyan); - outline: none; -} - -.function-strip .strip-input:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Wider input for frequency values */ -.function-strip .strip-input.wide { - width: 70px; -} - -/* Tool Status Indicators */ -.function-strip .strip-tools { - display: flex; - gap: 4px; -} - -.function-strip .strip-tool { - font-size: 9px; - font-weight: 600; - padding: 3px 6px; - border-radius: 3px; - background: rgba(255, 59, 48, 0.2); - color: var(--accent-red); - border: 1px solid rgba(255, 59, 48, 0.3); -} - -.function-strip .strip-tool.ok { - background: rgba(0, 255, 136, 0.1); - color: var(--accent-green); - border-color: rgba(0, 255, 136, 0.3); -} - -.function-strip .strip-tool.warn { - background: rgba(255, 193, 7, 0.2); - color: var(--accent-yellow); - border-color: rgba(255, 193, 7, 0.3); -} - -/* Buttons */ -.function-strip .strip-btn { - background: rgba(74, 158, 255, 0.1); - border: 1px solid rgba(74, 158, 255, 0.2); - color: var(--text-primary); - padding: 6px 12px; - border-radius: 4px; - font-size: 10px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - white-space: nowrap; -} - -.function-strip .strip-btn:hover:not(:disabled) { - background: rgba(74, 158, 255, 0.2); - border-color: rgba(74, 158, 255, 0.4); -} - -.function-strip .strip-btn.primary { - background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%); - border: none; - color: #000; -} - -.function-strip .strip-btn.primary:hover:not(:disabled) { - filter: brightness(1.1); -} - -.function-strip .strip-btn.stop { - background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); - border: none; - color: #fff; -} - -.function-strip .strip-btn.stop:hover:not(:disabled) { - filter: brightness(1.1); -} - -.function-strip .strip-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Status indicator */ -.function-strip .strip-status { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background: rgba(0,0,0,0.2); - border-radius: 4px; - font-size: 10px; - font-weight: 600; - color: var(--text-secondary); -} - -.function-strip .status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-muted); -} - -.function-strip .status-dot.inactive { - background: var(--text-muted); -} - -.function-strip .status-dot.active, -.function-strip .status-dot.scanning, -.function-strip .status-dot.decoding { - background: var(--accent-cyan); - animation: strip-pulse 1.5s ease-in-out infinite; -} - -.function-strip .status-dot.listening, -.function-strip .status-dot.tracking, -.function-strip .status-dot.receiving { - background: var(--accent-green); - animation: strip-pulse 1.5s ease-in-out infinite; -} - -.function-strip .status-dot.sweeping { - background: var(--accent-orange); - animation: strip-pulse 1s ease-in-out infinite; -} - -.function-strip .status-dot.error { - background: var(--accent-red); -} - -@keyframes strip-pulse { - 0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; } - 50% { opacity: 0.6; box-shadow: none; } -} - -/* Time display */ -.function-strip .strip-time { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-muted); - padding: 4px 8px; - background: rgba(0,0,0,0.2); - border-radius: 4px; - white-space: nowrap; -} - -/* Mode-specific accent colors */ -.function-strip.pager-strip .strip-stat { - background: rgba(255, 193, 7, 0.05); - border-color: rgba(255, 193, 7, 0.15); -} -.function-strip.pager-strip .strip-stat:hover { - background: rgba(255, 193, 7, 0.1); - border-color: rgba(255, 193, 7, 0.3); -} -.function-strip.pager-strip .strip-value { - color: var(--accent-yellow); -} - -.function-strip.sensor-strip .strip-stat { - background: rgba(0, 255, 136, 0.05); - border-color: rgba(0, 255, 136, 0.15); -} -.function-strip.sensor-strip .strip-stat:hover { - background: rgba(0, 255, 136, 0.1); - border-color: rgba(0, 255, 136, 0.3); -} -.function-strip.sensor-strip .strip-value { - color: var(--accent-green); -} - -.function-strip.bt-strip .strip-stat { - background: rgba(0, 122, 255, 0.05); - border-color: rgba(0, 122, 255, 0.15); -} -.function-strip.bt-strip .strip-stat:hover { - background: rgba(0, 122, 255, 0.1); - border-color: rgba(0, 122, 255, 0.3); -} -.function-strip.bt-strip .strip-value { - color: #0a84ff; -} - -.function-strip.wifi-strip .strip-stat { - background: rgba(255, 149, 0, 0.05); - border-color: rgba(255, 149, 0, 0.15); -} -.function-strip.wifi-strip .strip-stat:hover { - background: rgba(255, 149, 0, 0.1); - border-color: rgba(255, 149, 0, 0.3); -} -.function-strip.wifi-strip .strip-value { - color: var(--accent-orange); -} - -.function-strip.tscm-strip { - margin-top: 4px; /* Extra clearance to prevent top clipping */ -} - -.function-strip.tscm-strip .strip-stat { - background: rgba(255, 59, 48, 0.15); - border: 1px solid rgba(255, 59, 48, 0.4); -} -.function-strip.tscm-strip .strip-stat:hover { - background: rgba(255, 59, 48, 0.25); - border-color: rgba(255, 59, 48, 0.6); -} -.function-strip.tscm-strip .strip-value { - color: #ef4444; /* Explicit red color */ -} -.function-strip.tscm-strip .strip-label { - color: #9ca3af; /* Explicit light gray */ -} -.function-strip.tscm-strip .strip-select { - color: #e8eaed; /* Explicit white for selects */ - background: rgba(0, 0, 0, 0.4); -} -.function-strip.tscm-strip .strip-btn { - color: #e8eaed; /* Explicit white for buttons */ -} -.function-strip.tscm-strip .strip-tool { - color: #e8eaed; /* Explicit white for tool indicators */ -} -.function-strip.tscm-strip .strip-time, -.function-strip.tscm-strip .strip-status span { - color: #9ca3af; /* Explicit gray for status/time */ -} - -.function-strip.rtlamr-strip .strip-stat { - background: rgba(175, 82, 222, 0.05); - border-color: rgba(175, 82, 222, 0.15); -} -.function-strip.rtlamr-strip .strip-stat:hover { - background: rgba(175, 82, 222, 0.1); - border-color: rgba(175, 82, 222, 0.3); -} -.function-strip.rtlamr-strip .strip-value { - color: #af52de; -} - -.function-strip.listening-strip .strip-stat { - background: rgba(74, 158, 255, 0.05); - border-color: rgba(74, 158, 255, 0.15); -} -.function-strip.listening-strip .strip-stat:hover { - background: rgba(74, 158, 255, 0.1); - border-color: rgba(74, 158, 255, 0.3); -} -.function-strip.listening-strip .strip-value { - color: var(--accent-cyan); -} - -/* Threat-colored stats for TSCM */ -.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); } -.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); } -.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); } +/* Function Strip (Action Bar) - Shared across modes + * Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc. + */ + +.function-strip { + background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px 12px; + margin-bottom: 10px; + overflow: visible; + min-height: 44px; +} + +.function-strip-inner { + display: flex; + align-items: center; + gap: 8px; + min-width: max-content; +} + +/* Stats */ +.function-strip .strip-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 6px 10px; + background: rgba(74, 158, 255, 0.05); + border: 1px solid rgba(74, 158, 255, 0.15); + border-radius: 4px; + min-width: 55px; +} + +.function-strip .strip-stat:hover { + background: rgba(74, 158, 255, 0.1); + border-color: rgba(74, 158, 255, 0.3); +} + +.function-strip .strip-value { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--accent-cyan); + line-height: 1.2; +} + +.function-strip .strip-label { + font-size: 8px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 1px; +} + +.function-strip .strip-divider { + width: 1px; + height: 28px; + background: var(--border-color); + margin: 0 4px; +} + +/* Signal stat coloring */ +.function-strip .signal-stat.good .strip-value { color: var(--accent-green); } +.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); } +.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); } + +/* Controls */ +.function-strip .strip-control { + display: flex; + align-items: center; + gap: 4px; +} + +.function-strip .strip-select { + background: rgba(0,0,0,0.3); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 4px 8px; + border-radius: 4px; + font-size: 10px; + cursor: pointer; +} + +.function-strip .strip-select:hover { + border-color: var(--accent-cyan); +} + +.function-strip .strip-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.function-strip .strip-input-label { + font-size: 9px; + color: var(--text-muted); + font-weight: 600; +} + +.function-strip .strip-input { + background: rgba(0,0,0,0.3); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 4px 6px; + border-radius: 4px; + font-size: 10px; + width: 50px; + text-align: center; +} + +.function-strip .strip-input:hover, +.function-strip .strip-input:focus { + border-color: var(--accent-cyan); + outline: none; +} + +.function-strip .strip-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Wider input for frequency values */ +.function-strip .strip-input.wide { + width: 70px; +} + +/* Tool Status Indicators */ +.function-strip .strip-tools { + display: flex; + gap: 4px; +} + +.function-strip .strip-tool { + font-size: 9px; + font-weight: 600; + padding: 3px 6px; + border-radius: 3px; + background: rgba(255, 59, 48, 0.2); + color: var(--accent-red); + border: 1px solid rgba(255, 59, 48, 0.3); +} + +.function-strip .strip-tool.ok { + background: rgba(0, 255, 136, 0.1); + color: var(--accent-green); + border-color: rgba(0, 255, 136, 0.3); +} + +.function-strip .strip-tool.warn { + background: rgba(255, 193, 7, 0.2); + color: var(--accent-yellow); + border-color: rgba(255, 193, 7, 0.3); +} + +/* Buttons */ +.function-strip .strip-btn { + background: rgba(74, 158, 255, 0.1); + border: 1px solid rgba(74, 158, 255, 0.2); + color: var(--text-primary); + padding: 6px 12px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.function-strip .strip-btn:hover:not(:disabled) { + background: rgba(74, 158, 255, 0.2); + border-color: rgba(74, 158, 255, 0.4); +} + +.function-strip .strip-btn.primary { + background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%); + border: none; + color: #000; +} + +.function-strip .strip-btn.primary:hover:not(:disabled) { + filter: brightness(1.1); +} + +.function-strip .strip-btn.stop { + background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); + border: none; + color: #fff; +} + +.function-strip .strip-btn.stop:hover:not(:disabled) { + filter: brightness(1.1); +} + +.function-strip .strip-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Status indicator */ +.function-strip .strip-status { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: rgba(0,0,0,0.2); + border-radius: 4px; + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); +} + +.function-strip .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); +} + +.function-strip .status-dot.inactive { + background: var(--text-muted); +} + +.function-strip .status-dot.active, +.function-strip .status-dot.scanning, +.function-strip .status-dot.decoding { + background: var(--accent-cyan); + animation: strip-pulse 1.5s ease-in-out infinite; +} + +.function-strip .status-dot.listening, +.function-strip .status-dot.tracking, +.function-strip .status-dot.receiving { + background: var(--accent-green); + animation: strip-pulse 1.5s ease-in-out infinite; +} + +.function-strip .status-dot.sweeping { + background: var(--accent-orange); + animation: strip-pulse 1s ease-in-out infinite; +} + +.function-strip .status-dot.error { + background: var(--accent-red); +} + +@keyframes strip-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; } + 50% { opacity: 0.6; box-shadow: none; } +} + +/* Time display */ +.function-strip .strip-time { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + padding: 4px 8px; + background: rgba(0,0,0,0.2); + border-radius: 4px; + white-space: nowrap; +} + +/* Mode-specific accent colors */ +.function-strip.pager-strip .strip-stat { + background: rgba(255, 193, 7, 0.05); + border-color: rgba(255, 193, 7, 0.15); +} +.function-strip.pager-strip .strip-stat:hover { + background: rgba(255, 193, 7, 0.1); + border-color: rgba(255, 193, 7, 0.3); +} +.function-strip.pager-strip .strip-value { + color: var(--accent-yellow); +} + +.function-strip.sensor-strip .strip-stat { + background: rgba(0, 255, 136, 0.05); + border-color: rgba(0, 255, 136, 0.15); +} +.function-strip.sensor-strip .strip-stat:hover { + background: rgba(0, 255, 136, 0.1); + border-color: rgba(0, 255, 136, 0.3); +} +.function-strip.sensor-strip .strip-value { + color: var(--accent-green); +} + +.function-strip.bt-strip .strip-stat { + background: rgba(0, 122, 255, 0.05); + border-color: rgba(0, 122, 255, 0.15); +} +.function-strip.bt-strip .strip-stat:hover { + background: rgba(0, 122, 255, 0.1); + border-color: rgba(0, 122, 255, 0.3); +} +.function-strip.bt-strip .strip-value { + color: #0a84ff; +} + +.function-strip.wifi-strip .strip-stat { + background: rgba(255, 149, 0, 0.05); + border-color: rgba(255, 149, 0, 0.15); +} +.function-strip.wifi-strip .strip-stat:hover { + background: rgba(255, 149, 0, 0.1); + border-color: rgba(255, 149, 0, 0.3); +} +.function-strip.wifi-strip .strip-value { + color: var(--accent-orange); +} + +.function-strip.tscm-strip { + margin-top: 4px; /* Extra clearance to prevent top clipping */ +} + +.function-strip.tscm-strip .strip-stat { + background: rgba(255, 59, 48, 0.15); + border: 1px solid rgba(255, 59, 48, 0.4); +} +.function-strip.tscm-strip .strip-stat:hover { + background: rgba(255, 59, 48, 0.25); + border-color: rgba(255, 59, 48, 0.6); +} +.function-strip.tscm-strip .strip-value { + color: #ef4444; /* Explicit red color */ +} +.function-strip.tscm-strip .strip-label { + color: #9ca3af; /* Explicit light gray */ +} +.function-strip.tscm-strip .strip-select { + color: #e8eaed; /* Explicit white for selects */ + background: rgba(0, 0, 0, 0.4); +} +.function-strip.tscm-strip .strip-btn { + color: #e8eaed; /* Explicit white for buttons */ +} +.function-strip.tscm-strip .strip-tool { + color: #e8eaed; /* Explicit white for tool indicators */ +} +.function-strip.tscm-strip .strip-time, +.function-strip.tscm-strip .strip-status span { + color: #9ca3af; /* Explicit gray for status/time */ +} + +.function-strip.rtlamr-strip .strip-stat { + background: rgba(175, 82, 222, 0.05); + border-color: rgba(175, 82, 222, 0.15); +} +.function-strip.rtlamr-strip .strip-stat:hover { + background: rgba(175, 82, 222, 0.1); + border-color: rgba(175, 82, 222, 0.3); +} +.function-strip.rtlamr-strip .strip-value { + color: #af52de; +} + +.function-strip.listening-strip .strip-stat { + background: rgba(74, 158, 255, 0.05); + border-color: rgba(74, 158, 255, 0.15); +} +.function-strip.listening-strip .strip-stat:hover { + background: rgba(74, 158, 255, 0.1); + border-color: rgba(74, 158, 255, 0.3); +} +.function-strip.listening-strip .strip-value { + color: var(--accent-cyan); +} + +/* Threat-colored stats for TSCM */ +.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); } +.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); } +.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); } diff --git a/static/css/components/signal-cards.css b/static/css/components/signal-cards.css index f0814d8..19e5e73 100644 --- a/static/css/components/signal-cards.css +++ b/static/css/components/signal-cards.css @@ -1,1934 +1,1934 @@ -/** - * Signal Cards Component System - * Reusable card components for displaying RF signals and decoded messages - * Used across: Pager, APRS, Sensors, and other signal-based modes - */ - -/* ============================================ - STATUS COLORS & VARIABLES - ============================================ */ -:root { - /* Signal status colors */ - --signal-new: #3b82f6; - --signal-new-bg: rgba(59, 130, 246, 0.12); - --signal-baseline: #6b7280; - --signal-baseline-bg: rgba(107, 114, 128, 0.08); - --signal-burst: #f59e0b; - --signal-burst-bg: rgba(245, 158, 11, 0.12); - --signal-repeated: #eab308; - --signal-repeated-bg: rgba(234, 179, 8, 0.10); - --signal-emergency: #ef4444; - --signal-emergency-bg: rgba(239, 68, 68, 0.15); - - /* Protocol colors */ - --proto-pocsag: var(--accent-cyan, #4a9eff); - --proto-flex: var(--accent-amber, #f59e0b); - --proto-aprs: #06b6d4; - --proto-ais: #8b5cf6; - --proto-acars: #ec4899; -} - -/* ============================================ - SIGNAL FEED CONTAINER - ============================================ */ -.signal-feed { - display: flex; - flex-direction: column; - gap: 8px; -} - -.signal-feed-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 0; - border-bottom: 1px solid var(--border-color); - margin-bottom: 8px; -} - -.signal-feed-title { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--text-secondary); -} - -.signal-feed-live { - display: flex; - align-items: center; - gap: 8px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--accent-green); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.signal-feed-live .live-dot { - width: 8px; - height: 8px; - background: var(--accent-green); - border-radius: 50%; - animation: signalPulse 2s ease-in-out infinite; -} - -@keyframes signalPulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(0.85); } -} - -/* ============================================ - FILTER BAR - ============================================ */ -.signal-filter-bar { - display: flex; - align-items: center; - gap: 6px; - padding: 10px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; - margin-bottom: 12px; - flex-wrap: wrap; -} - -.signal-filter-label { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-dim); - margin-right: 6px; -} - -.signal-filter-btn { - display: inline-flex; - align-items: center; - gap: 5px; - font-family: 'Inter', sans-serif; - font-size: 11px; - font-weight: 500; - padding: 5px 10px; - border-radius: 4px; - border: 1px solid var(--border-color); - background: var(--bg-card); - color: var(--text-secondary); - cursor: pointer; - transition: all 0.15s ease; -} - -.signal-filter-btn:hover { - border-color: var(--border-light); - background: var(--bg-elevated); -} - -.signal-filter-btn.active { - border-color: var(--accent-cyan); - background: var(--accent-cyan-dim); - color: var(--accent-cyan); -} - -.signal-filter-btn .filter-dot { - width: 6px; - height: 6px; - border-radius: 50%; -} - -.signal-filter-btn[data-filter="all"] .filter-dot { background: var(--text-secondary); } -.signal-filter-btn[data-filter="emergency"] .filter-dot { background: var(--signal-emergency); } -.signal-filter-btn[data-filter="new"] .filter-dot { background: var(--signal-new); } -.signal-filter-btn[data-filter="burst"] .filter-dot { background: var(--signal-burst); } -.signal-filter-btn[data-filter="repeated"] .filter-dot { background: var(--signal-repeated); } -.signal-filter-btn[data-filter="baseline"] .filter-dot { background: var(--signal-baseline); } - -.signal-filter-count { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - background: var(--bg-secondary); - padding: 1px 5px; - border-radius: 3px; - color: var(--text-dim); -} - -.signal-filter-btn.active .signal-filter-count { - background: rgba(74, 158, 255, 0.2); - color: var(--accent-cyan); -} - -.signal-filter-divider { - width: 1px; - height: 20px; - background: var(--border-color); - margin: 0 6px; -} - -/* ============================================ - BASE SIGNAL CARD - ============================================ */ -.signal-card { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 12px; - transition: all 0.2s ease; - position: relative; - overflow: hidden; - animation: cardSlideIn 0.25s ease; -} - -@keyframes cardSlideIn { - from { - opacity: 0; - transform: translateY(-6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.signal-card:hover { - background: var(--bg-elevated); - border-color: var(--border-light); -} - -.signal-card.hidden { - display: none; -} - -/* Left accent border for status */ -.signal-card::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 3px; - background: var(--card-accent, transparent); -} - -/* ============================================ - SIGNAL CARD STATUS VARIANTS - ============================================ */ -.signal-card[data-status="new"] { - --card-accent: var(--signal-new); - background: linear-gradient(90deg, var(--signal-new-bg) 0%, var(--bg-card) 35%); -} - -.signal-card[data-status="burst"] { - --card-accent: var(--signal-burst); - background: linear-gradient(90deg, var(--signal-burst-bg) 0%, var(--bg-card) 35%); -} - -.signal-card[data-status="repeated"] { - --card-accent: var(--signal-repeated); -} - -.signal-card[data-status="baseline"] { - --card-accent: var(--signal-baseline); -} - -.signal-card[data-status="emergency"] { - --card-accent: var(--signal-emergency); - background: linear-gradient(90deg, var(--signal-emergency-bg) 0%, var(--bg-card) 35%); - border-color: rgba(239, 68, 68, 0.3); -} - -/* ============================================ - CARD HEADER - ============================================ */ -.signal-card-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 10px; -} - -.signal-card-badges { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -/* Protocol badge */ -.signal-proto-badge { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 3px 7px; - border-radius: 3px; - border: 1px solid; -} - -.signal-proto-badge.pocsag { - background: var(--accent-cyan-dim); - color: var(--accent-cyan); - border-color: rgba(74, 158, 255, 0.25); -} - -.signal-proto-badge.flex { - background: var(--accent-amber-dim); - color: var(--accent-amber); - border-color: rgba(212, 168, 83, 0.25); -} - -.signal-proto-badge.aprs { - background: rgba(6, 182, 212, 0.15); - color: #06b6d4; - border-color: rgba(6, 182, 212, 0.25); -} - -.signal-proto-badge.ais { - background: rgba(139, 92, 246, 0.15); - color: #8b5cf6; - border-color: rgba(139, 92, 246, 0.25); -} - -.signal-proto-badge.acars { - background: rgba(236, 72, 153, 0.15); - color: #ec4899; - border-color: rgba(236, 72, 153, 0.25); -} - -/* Frequency/Address badge */ -.signal-freq-badge { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - font-weight: 500; - color: var(--text-primary); - background: var(--bg-secondary); - padding: 3px 8px; - border-radius: 3px; - border: 1px solid var(--border-color); -} - -/* Status pill */ -.signal-status-pill { - display: inline-flex; - align-items: center; - gap: 5px; - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 3px 8px; - border-radius: 10px; -} - -.signal-status-pill .status-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: currentColor; -} - -.signal-status-pill[data-status="new"] { - background: var(--signal-new-bg); - color: var(--signal-new); -} - -.signal-status-pill[data-status="baseline"] { - background: var(--signal-baseline-bg); - color: var(--signal-baseline); -} - -.signal-status-pill[data-status="burst"] { - background: var(--signal-burst-bg); - color: var(--signal-burst); -} - -.signal-status-pill[data-status="repeated"] { - background: var(--signal-repeated-bg); - color: var(--signal-repeated); -} - -.signal-status-pill[data-status="emergency"] { - background: var(--signal-emergency-bg); - color: var(--signal-emergency); -} - -.signal-status-pill[data-status="new"] .status-dot, -.signal-status-pill[data-status="burst"] .status-dot, -.signal-status-pill[data-status="emergency"] .status-dot { - animation: signalPulse 1.5s ease-in-out infinite; -} - -/* ============================================ - CARD BODY - ============================================ */ -.signal-card-body { - display: flex; - flex-direction: column; - gap: 8px; -} - -/* Message metadata row */ -.signal-meta-row { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.signal-sender { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--accent-green); -} - -.signal-msg-type { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-dim); - background: var(--bg-secondary); - padding: 2px 6px; - border-radius: 3px; -} - -.signal-timestamp { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-dim); - margin-left: auto; -} - -/* Message content preview */ -.signal-message { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - color: var(--text-primary); - background: var(--bg-secondary); - padding: 10px; - border-radius: 4px; - border-left: 2px solid var(--border-color); - line-height: 1.5; - word-break: break-word; -} - -.signal-message.numeric { - font-size: 14px; - letter-spacing: 1.5px; -} - -.signal-message.emergency { - border-left-color: var(--signal-emergency); - background: var(--signal-emergency-bg); -} - -.signal-message.truncated::after { - content: '...'; - color: var(--text-dim); -} - -/* Signal strength indicator */ -.signal-strength-row { - display: flex; - align-items: center; - gap: 12px; -} - -.signal-strength-bars { - display: flex; - align-items: flex-end; - gap: 2px; - height: 16px; -} - -.signal-strength-bars .bar { - width: 3px; - background: var(--border-light); - border-radius: 1px; - transition: background 0.2s; -} - -.signal-strength-bars .bar:nth-child(1) { height: 5px; } -.signal-strength-bars .bar:nth-child(2) { height: 8px; } -.signal-strength-bars .bar:nth-child(3) { height: 11px; } -.signal-strength-bars .bar:nth-child(4) { height: 14px; } -.signal-strength-bars .bar:nth-child(5) { height: 16px; } - -.signal-strength-bars .bar.active { - background: var(--accent-green); -} - -.signal-strength-bars .bar.active.weak { - background: var(--accent-orange); -} - -.signal-activity { - font-size: 12px; - color: var(--text-secondary); -} - -/* Behavior tag */ -.signal-behavior { - display: inline-flex; - align-items: center; - gap: 5px; - font-size: 11px; - color: var(--text-dim); - background: var(--bg-secondary); - padding: 5px 8px; - border-radius: 4px; - width: fit-content; -} - -.signal-behavior svg { - width: 12px; - height: 12px; - opacity: 0.7; -} - -/* ============================================ - CARD FOOTER & ACTIONS - ============================================ */ -.signal-card-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--border-color); -} - -.signal-advanced-toggle { - display: flex; - align-items: center; - gap: 5px; - font-size: 11px; - color: var(--text-dim); - background: none; - border: none; - cursor: pointer; - padding: 4px 6px; - margin: -4px -6px; - border-radius: 4px; - transition: all 0.15s; -} - -.signal-advanced-toggle:hover { - color: var(--text-secondary); - background: var(--bg-secondary); -} - -.signal-advanced-toggle svg { - width: 12px; - height: 12px; - transition: transform 0.2s; -} - -.signal-advanced-toggle.open svg { - transform: rotate(180deg); -} - -.signal-advanced-toggle.open { - color: var(--accent-cyan); -} - -.signal-card-actions { - display: flex; - gap: 6px; -} - -.signal-action-btn { - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-dim); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - padding: 5px 8px; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s; -} - -.signal-action-btn:hover { - color: var(--text-secondary); - border-color: var(--border-light); -} - -.signal-action-btn.primary { - background: var(--accent-cyan-dim); - border-color: rgba(74, 158, 255, 0.25); - color: var(--accent-cyan); -} - -.signal-action-btn.primary:hover { - background: rgba(74, 158, 255, 0.2); -} - -.signal-action-btn.danger { - background: var(--accent-red-dim); - border-color: rgba(239, 68, 68, 0.25); - color: var(--accent-red); -} - -.signal-action-btn.danger:hover { - background: rgba(239, 68, 68, 0.2); -} - -/* ============================================ - ADVANCED PANEL - ============================================ */ -.signal-advanced-panel { - display: grid; - grid-template-rows: 0fr; - transition: grid-template-rows 0.2s ease; - margin-top: 0; -} - -.signal-advanced-panel.open { - grid-template-rows: 1fr; - margin-top: 10px; -} - -.signal-advanced-inner { - overflow: hidden; -} - -.signal-advanced-content { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 4px; - padding: 10px; -} - -.signal-advanced-section { - margin-bottom: 10px; -} - -.signal-advanced-section:last-child { - margin-bottom: 0; -} - -.signal-advanced-title { - font-size: 9px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-dim); - margin-bottom: 6px; -} - -.signal-advanced-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 6px; -} - -.signal-advanced-item { - display: flex; - flex-direction: column; - gap: 1px; -} - -.signal-advanced-label { - font-size: 9px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.signal-advanced-value { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-primary); -} - -.signal-raw-data { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-secondary); - background: var(--bg-primary); - padding: 8px; - border-radius: 3px; - overflow-x: auto; - white-space: pre-wrap; - word-break: break-all; - line-height: 1.5; -} - -.signal-advanced-actions { - display: flex; - gap: 6px; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--border-color); -} - -/* ============================================ - MINI MAP (for APRS/position data) - ============================================ */ -.signal-mini-map { - width: 100%; - height: 70px; - background: var(--bg-secondary); - border-radius: 4px; - border: 1px solid var(--border-color); - display: flex; - align-items: center; - justify-content: center; - position: relative; - overflow: hidden; - cursor: pointer; - transition: border-color 0.15s; -} - -.signal-mini-map:hover { - border-color: var(--accent-cyan); -} - -.signal-mini-map::before { - content: ''; - position: absolute; - inset: 0; - background-image: - linear-gradient(rgba(74, 158, 255, 0.08) 1px, transparent 1px), - linear-gradient(90deg, rgba(74, 158, 255, 0.08) 1px, transparent 1px); - background-size: 14px 14px; -} - -.signal-map-pin { - width: 10px; - height: 10px; - background: var(--signal-emergency); - border-radius: 50%; - border: 2px solid var(--bg-card); - box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.3); - animation: mapPing 1.5s ease-out infinite; - z-index: 1; -} - -@keyframes mapPing { - 0% { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.4); } - 100% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); } -} - -.signal-map-coords { - position: absolute; - bottom: 4px; - right: 6px; - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - color: var(--text-dim); - background: var(--bg-card); - padding: 2px 5px; - border-radius: 2px; -} - -/* ============================================ - SPARKLINE CHART - ============================================ */ -.signal-sparkline-container { - margin-top: 6px; -} - -.signal-sparkline-label { - font-size: 9px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 3px; -} - -.signal-sparkline { - display: flex; - align-items: flex-end; - gap: 1px; - height: 28px; - padding: 3px; - background: var(--bg-primary); - border-radius: 3px; -} - -.signal-sparkline .spark-bar { - flex: 1; - background: var(--accent-cyan); - border-radius: 1px; - opacity: 0.6; - transition: opacity 0.15s; -} - -.signal-sparkline .spark-bar:hover { - opacity: 1; -} - -.signal-sparkline .spark-bar.spike { - background: var(--signal-burst); - opacity: 0.9; -} - -/* ============================================ - TOAST NOTIFICATION - ============================================ */ -.signal-toast { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%) translateY(80px); - background: var(--bg-card); - border: 1px solid var(--border-light); - padding: 10px 18px; - border-radius: 6px; - font-size: 12px; - color: var(--text-primary); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4); - z-index: 10000; - opacity: 0; - transition: all 0.25s ease; -} - -.signal-toast.show { - transform: translateX(-50%) translateY(0); - opacity: 1; -} - -.signal-toast.success { - border-color: var(--accent-green); - background: linear-gradient(90deg, var(--accent-green-dim) 0%, var(--bg-card) 40%); -} - -.signal-toast.error { - border-color: var(--accent-red); - background: linear-gradient(90deg, var(--accent-red-dim) 0%, var(--bg-card) 40%); -} - -/* ============================================ - EMPTY STATE - ============================================ */ -.signal-empty-state { - text-align: center; - padding: 40px 20px; - color: var(--text-dim); -} - -.signal-empty-state svg { - width: 40px; - height: 40px; - margin-bottom: 12px; - opacity: 0.5; -} - -.signal-empty-state p { - font-size: 13px; -} - -/* ============================================ - COMPACT MODE (for high-density display) - ============================================ */ -.signal-feed.compact .signal-card { - padding: 8px 10px; -} - -.signal-feed.compact .signal-card-header { - margin-bottom: 6px; -} - -.signal-feed.compact .signal-proto-badge { - font-size: 9px; - padding: 2px 5px; -} - -.signal-feed.compact .signal-freq-badge { - font-size: 10px; - padding: 2px 6px; -} - -.signal-feed.compact .signal-message { - font-size: 11px; - padding: 8px; -} - -.signal-feed.compact .signal-card-footer { - margin-top: 8px; - padding-top: 8px; -} - -/* ============================================ - SEARCH INPUT - ============================================ */ -.signal-search-container { - flex: 1; - min-width: 150px; - max-width: 250px; -} - -.signal-search-input { - width: 100%; - padding: 6px 10px; - font-family: 'Inter', sans-serif; - font-size: 11px; - color: var(--text-primary); - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 4px; - outline: none; - transition: all 0.15s; -} - -.signal-search-input::placeholder { - color: var(--text-dim); -} - -.signal-search-input:focus { - border-color: var(--accent-cyan); - background: var(--bg-elevated); -} - -/* ============================================ - SEEN COUNT BADGE - ============================================ */ -.signal-seen-count { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-dim); - background: var(--bg-secondary); - padding: 2px 5px; - border-radius: 3px; -} - -/* ============================================ - SENSOR DATA DISPLAY - ============================================ */ -.signal-sensor-data { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 10px; - background: var(--bg-secondary); - border-radius: 4px; - border-left: 2px solid var(--accent-cyan); -} - -.signal-sensor-reading { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 70px; -} - -.signal-sensor-reading .sensor-label { - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-dim); -} - -.signal-sensor-reading .sensor-value { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 500; - color: var(--text-primary); -} - -.signal-sensor-reading .sensor-value.low-battery { - color: var(--accent-red); -} - -/* Sensor protocol badge */ -.signal-proto-badge.sensor { - background: var(--accent-green-dim); - color: var(--accent-green); - border-color: rgba(34, 197, 94, 0.25); -} - -/* ============================================ - METER DATA DISPLAY - ============================================ */ -.signal-meter-data { - display: flex; - flex-wrap: wrap; - gap: 12px; - padding: 12px; - background: var(--bg-secondary); - border-radius: 4px; - border-left: 2px solid var(--accent-yellow); -} - -.signal-meter-reading { - display: flex; - flex-direction: column; - gap: 3px; -} - -.signal-meter-reading .meter-label { - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-dim); -} - -.signal-meter-reading .meter-value { - font-family: 'JetBrains Mono', monospace; - font-size: 18px; - font-weight: 600; - color: var(--text-primary); -} - -/* Meter protocol badges */ -.signal-proto-badge.meter { - background: rgba(234, 179, 8, 0.15); - color: #eab308; - border-color: rgba(234, 179, 8, 0.25); -} - -.signal-proto-badge.meter.electric { - background: rgba(234, 179, 8, 0.15); - color: #eab308; - border-color: rgba(234, 179, 8, 0.25); -} - -.signal-proto-badge.meter.gas { - background: rgba(249, 115, 22, 0.15); - color: #f97316; - border-color: rgba(249, 115, 22, 0.25); -} - -.signal-proto-badge.meter.water { - background: rgba(59, 130, 246, 0.15); - color: #3b82f6; - border-color: rgba(59, 130, 246, 0.25); -} - -/* ============================================ - AGGREGATED METER CARD - ============================================ */ -.signal-card.meter-aggregated { - /* Inherit standard signal-card styles */ -} - -.meter-aggregated-grid { - display: grid; - grid-template-columns: 1fr 1.2fr 0.8fr; - gap: 12px; - padding: 12px; - background: var(--bg-secondary); - border-radius: 4px; - border-left: 2px solid var(--accent-yellow); - align-items: start; -} - -.meter-aggregated-col { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; /* Allow column to shrink in grid */ - overflow: hidden; -} - -.meter-aggregated-label { - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-dim); -} - -.meter-aggregated-value { - font-family: 'JetBrains Mono', monospace; - font-size: 16px; - font-weight: 600; - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Consumption column */ -.consumption-col .consumption-value { - font-size: 18px; - line-height: 1.2; -} - -/* Delta badge */ -.meter-delta { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - padding: 2px 6px; - border-radius: 3px; - width: fit-content; - background: var(--bg-tertiary, rgba(255, 255, 255, 0.05)); - color: var(--text-dim); -} - -.meter-delta.positive { - background: rgba(34, 197, 94, 0.15); - color: #22c55e; -} - -.meter-delta.negative { - background: rgba(239, 68, 68, 0.15); - color: #ef4444; -} - -/* Sparkline container */ -.meter-sparkline-container { - min-height: 28px; - display: flex; - align-items: center; - max-width: 100%; - overflow: hidden; -} - -.meter-sparkline-placeholder { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-dim); -} - -/* Rate display */ -.meter-rate-value { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 500; - color: var(--accent-cyan, #4a9eff); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Update animation */ -.signal-card.meter-updated { - animation: meterUpdatePulse 0.3s ease; -} - -@keyframes meterUpdatePulse { - 0% { - box-shadow: 0 0 0 0 rgba(234, 179, 8, 0.4); - } - 50% { - box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.2); - } - 100% { - box-shadow: 0 0 0 0 rgba(234, 179, 8, 0); - } -} - -/* Consumption sparkline styles */ -.consumption-sparkline-svg { - display: block; -} - -.consumption-sparkline-wrapper { - display: flex; - align-items: center; - gap: 6px; -} - -.consumption-trend { - font-size: 14px; - font-weight: 500; -} - -/* Responsive adjustments for aggregated meters */ -@media (max-width: 500px) { - .meter-aggregated-grid { - grid-template-columns: 1fr 1fr; - grid-template-rows: auto auto; - } - - .meter-aggregated-col.trend-col { - grid-column: 1 / -1; - } - - .meter-sparkline-container { - width: 100%; - } - - .meter-sparkline-container svg { - width: 100%; - } -} - -/* ============================================ - APRS SYMBOL - ============================================ */ -.signal-aprs-symbol { - font-size: 12px; - padding: 2px 4px; - background: var(--bg-secondary); - border-radius: 3px; -} - -/* ============================================ - DISTANCE DISPLAY - ============================================ */ -.signal-distance { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--accent-green); - font-weight: 500; -} - -/* ============================================ - COMPACT CARD VARIANT - For constrained layouts like APRS station list - ============================================ */ -.signal-card-compact { - padding: 10px 12px; -} - -.signal-card-compact .signal-card-header { - margin-bottom: 6px; -} - -.signal-card-compact .signal-proto-badge { - font-size: 9px; - padding: 2px 5px; -} - -.signal-card-compact .signal-freq-badge { - font-size: 10px; - padding: 2px 6px; -} - -.signal-card-compact .signal-status-pill { - font-size: 9px; - padding: 2px 6px; -} - -.signal-card-compact .signal-message { - font-size: 11px; - padding: 6px 8px; - max-height: 40px; -} - -.signal-card-compact .signal-meta-row { - font-size: 9px; -} - -.signal-card-compact .signal-mini-map { - padding: 6px 8px; - font-size: 10px; -} - -.signal-card-compact .signal-card-footer { - margin-top: 6px; - padding-top: 6px; -} - -.signal-card-compact .signal-advanced-toggle { - font-size: 9px; - padding: 3px 6px; -} - -/* Compact filter bar for APRS */ -.signal-filter-bar-compact { - padding: 6px 8px; - margin-bottom: 8px; - gap: 4px; -} - -.signal-filter-bar-compact .signal-filter-btn { - padding: 3px 6px; - font-size: 9px; -} - -.signal-filter-bar-compact .signal-filter-count { - font-size: 8px; - padding: 1px 3px; - min-width: 14px; -} - -.signal-filter-bar-compact .signal-search-input { - padding: 4px 8px; - font-size: 10px; -} - -.signal-filter-bar-compact .signal-filter-divider { - margin: 0 4px; -} - -/* ============================================ - TONE ONLY MESSAGE STYLING - ============================================ */ -.signal-message.tone-only { - color: var(--text-dim); - font-style: italic; - border-left-color: var(--border-color); -} - -/* ============================================ - FILTER BAR RESPONSIVE - ============================================ */ -@media (max-width: 768px) { - .signal-filter-bar { - flex-direction: column; - align-items: stretch; - gap: 8px; - } - - .signal-filter-bar > .signal-filter-label { - margin-top: 8px; - } - - .signal-filter-bar > .signal-filter-label:first-child { - margin-top: 0; - } - - .signal-filter-divider { - display: none; - } - - .signal-search-container { - max-width: none; - } - - .signal-filter-bar .signal-filter-btn { - flex: 1; - } -} - -/* ============================================ - SIGNAL STRENGTH INDICATOR - Classification-based signal display - ============================================ */ -.signal-strength-indicator { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 3px 8px; - background: var(--bg-secondary); - border-radius: 4px; - border: 1px solid var(--border-color); -} - -.signal-strength-indicator.compact { - padding: 2px 4px; - gap: 0; - background: transparent; - border: none; -} - -.signal-strength-indicator.no-data { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - color: var(--text-dim); - opacity: 0.5; -} - -.signal-strength-bars { - display: inline-block; - vertical-align: middle; -} - -.signal-strength-bars rect { - transition: fill 0.2s ease; -} - -.signal-strength-label { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -/* Confidence-based styling */ -.signal-confidence-low { - opacity: 0.7; -} - -.signal-confidence-medium { - opacity: 0.85; -} - -.signal-confidence-high { - opacity: 1; -} - -.signal-advanced-value.signal-confidence-low { - color: var(--text-dim); -} - -.signal-advanced-value.signal-confidence-medium { - color: var(--accent-amber); -} - -.signal-advanced-value.signal-confidence-high { - color: var(--accent-green); -} - -/* ============================================ - SIGNAL ASSESSMENT PANEL - Detailed signal analysis in advanced panel - ============================================ */ -.signal-assessment { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - padding: 10px; - margin-bottom: 12px; -} - -.signal-assessment-summary { - display: flex; - align-items: flex-start; - gap: 12px; - margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-color); -} - -.signal-assessment-text { - font-size: 12px; - color: var(--text-secondary); - line-height: 1.5; - flex: 1; -} - -.signal-assessment-caveat { - font-size: 10px; - color: var(--text-dim); - font-style: italic; - margin-top: 8px; - padding-top: 8px; - border-top: 1px dashed var(--border-color); - line-height: 1.4; -} - -/* Signal assessment confidence badges */ -.signal-assessment .signal-advanced-grid { - margin-top: 8px; -} - -/* Range estimate styling */ -.signal-range-estimate { - display: flex; - align-items: center; - gap: 8px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; -} - -.signal-range-estimate .range-value { - color: var(--accent-cyan); - font-weight: 500; -} - -.signal-range-estimate .range-disclaimer { - font-size: 9px; - color: var(--text-dim); - font-style: italic; -} - -/* ============================================ - Clickable Station Badge (APRS) - ============================================ */ - -.signal-station-clickable { - cursor: pointer; - transition: all 0.15s ease; - position: relative; -} - -.signal-station-clickable:hover { - background: var(--accent-purple); - color: #000; - transform: scale(1.05); - box-shadow: 0 0 8px rgba(138, 43, 226, 0.4); -} - -.signal-station-clickable:active { - transform: scale(0.98); -} - -.signal-station-clickable::after { - content: ''; - position: absolute; - bottom: -2px; - left: 50%; - transform: translateX(-50%); - width: 0; - height: 2px; - background: var(--accent-purple); - transition: width 0.2s ease; -} - -.signal-station-clickable:hover::after { - width: 80%; -} - -/* ============================================ - Station Raw Data Modal - ============================================ */ - -.station-raw-modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s ease, visibility 0.2s ease; -} - -.station-raw-modal.show { - opacity: 1; - visibility: visible; -} - -.station-raw-modal-backdrop { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); -} - -.station-raw-modal-content { - position: relative; - background: var(--panel-bg, #1a1a2e); - border: 1px solid var(--border-color, #333); - border-radius: 8px; - width: 90%; - max-width: 600px; - max-height: 80vh; - display: flex; - flex-direction: column; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - transform: scale(0.95); - transition: transform 0.2s ease; -} - -.station-raw-modal.show .station-raw-modal-content { - transform: scale(1); -} - -.station-raw-modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid var(--border-color, #333); - background: rgba(0, 0, 0, 0.2); - border-radius: 8px 8px 0 0; -} - -.station-raw-modal-title { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 600; - color: var(--accent-cyan, #00d4ff); -} - -.station-raw-modal-close { - background: none; - border: none; - color: var(--text-muted, #888); - font-size: 24px; - cursor: pointer; - padding: 0 4px; - line-height: 1; - transition: color 0.15s ease; -} - -.station-raw-modal-close:hover { - color: var(--accent-red, #ff4444); -} - -.station-raw-modal-body { - padding: 16px; - overflow-y: auto; - flex: 1; -} - -.station-raw-label { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 1px; - color: var(--text-muted, #888); - margin-bottom: 8px; -} - -.station-raw-data-display { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - line-height: 1.6; - color: var(--accent-green, #00ff88); - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--border-color, #333); - border-radius: 4px; - padding: 12px; - word-break: break-all; - white-space: pre-wrap; - margin: 0; - max-height: 300px; - overflow-y: auto; -} - -.station-raw-modal-footer { - display: flex; - justify-content: flex-end; - padding: 12px 16px; - border-top: 1px solid var(--border-color, #333); - background: rgba(0, 0, 0, 0.2); - border-radius: 0 0 8px 8px; -} - -.station-raw-copy-btn { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - padding: 8px 16px; - background: var(--accent-purple, #8a2be2); - border: none; - border-radius: 4px; - color: #fff; - cursor: pointer; - transition: all 0.15s ease; -} - -.station-raw-copy-btn:hover { - background: var(--accent-cyan, #00d4ff); - color: #000; -} - -/* ============================================ - SIGNAL GUESS INTEGRATION - ============================================ */ - -/* Signal guess badge in card header */ -.signal-card-badges .signal-guess-badge { - margin-left: 4px; -} - -/* Signal guess section in advanced panel */ -.signal-guess-section { - border-bottom: 1px solid var(--border-color); - padding-bottom: 12px; - margin-bottom: 12px; -} - -.signal-guess-section .signal-guess-container { - margin-top: 8px; -} - -/* Adjust guess label colors for dark theme */ -.signal-guess-section .signal-guess-label { - color: var(--text-primary, #e0e0e0); -} - -.signal-guess-section .signal-guess-tag { - background: var(--bg-tertiary, #2a2a2a); - color: var(--text-secondary, #888); -} - -.signal-guess-section .signal-guess-alt-item { - color: var(--text-secondary, #999); -} - -.signal-guess-section .signal-guess-popup-explanation { - color: var(--text-secondary, #aaa); -} - -/* ============================================ - CLICKABLE CARDS - ============================================ */ -.signal-card.signal-card-clickable { - cursor: pointer; - transition: all 0.15s ease; -} - -.signal-card.signal-card-clickable:hover { - border-color: var(--accent-cyan, #00d4ff); - box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2); -} - -.signal-card.signal-card-clickable:active { - transform: scale(0.995); -} - -/* Floating action buttons for clickable cards */ -.signal-card-actions-float { - position: absolute; - top: 8px; - right: 8px; - display: flex; - gap: 6px; - opacity: 0; - transition: opacity 0.15s ease; - z-index: 2; -} - -.signal-card.signal-card-clickable:hover .signal-card-actions-float { - opacity: 1; -} - -.signal-card-actions-float .signal-action-btn { - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-dim); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - padding: 4px 8px; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s; -} - -.signal-card-actions-float .signal-action-btn:hover { - color: var(--text-secondary); - border-color: var(--border-light); - background: var(--bg-tertiary); -} - -/* ============================================ - SIGNAL DETAILS MODAL - ============================================ */ -.signal-details-modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 10000; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s ease, visibility 0.2s ease; -} - -.signal-details-modal.show { - opacity: 1; - visibility: visible; -} - -.signal-details-modal-backdrop { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); -} - -.signal-details-modal-content { - position: relative; - background: var(--panel-bg, #1a1a2e); - border: 1px solid var(--border-color, #333); - border-radius: 12px; - width: 90%; - max-width: 600px; - max-height: 85vh; - display: flex; - flex-direction: column; - transform: scale(0.95); - transition: transform 0.2s ease; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); -} - -.signal-details-modal.show .signal-details-modal-content { - transform: scale(1); -} - -.signal-details-modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--border-color, #333); - background: rgba(0, 0, 0, 0.2); - border-radius: 12px 12px 0 0; -} - -.signal-details-modal-title { - font-family: 'JetBrains Mono', monospace; - font-size: 15px; - font-weight: 600; - color: var(--accent-cyan, #00d4ff); -} - -.signal-details-modal-close { - background: none; - border: none; - color: var(--text-muted, #888); - font-size: 24px; - cursor: pointer; - padding: 0; - line-height: 1; - transition: color 0.15s ease; -} - -.signal-details-modal-close:hover { - color: var(--accent-red, #ff4444); -} - -.signal-details-modal-body { - padding: 20px; - overflow-y: auto; - flex: 1; -} - -.signal-details-modal-footer { - display: flex; - justify-content: flex-end; - padding: 12px 20px; - border-top: 1px solid var(--border-color, #333); - background: rgba(0, 0, 0, 0.2); - border-radius: 0 0 12px 12px; -} - -.signal-details-copy-btn { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - padding: 8px 16px; - background: var(--accent-purple, #8a2be2); - border: none; - border-radius: 4px; - color: #fff; - cursor: pointer; - transition: all 0.15s ease; -} - -.signal-details-copy-btn:hover { - background: var(--accent-cyan, #00d4ff); - color: #000; -} - -/* Signal Details Content Sections */ -.signal-details-section { - margin-bottom: 20px; -} - -.signal-details-section:last-child { - margin-bottom: 0; -} - -.signal-details-title { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-dim, #666); - margin-bottom: 10px; - padding-bottom: 6px; - border-bottom: 1px solid var(--border-color, #333); -} - -.signal-details-message { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - color: var(--text-primary, #e0e0e0); - background: var(--bg-secondary, #252525); - padding: 12px 14px; - border-radius: 6px; - word-break: break-word; - line-height: 1.5; -} - -.signal-details-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 12px; -} - -.signal-details-item { - display: flex; - flex-direction: column; - gap: 2px; -} - -.signal-details-label { - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-dim, #666); -} - -.signal-details-value { - font-family: 'JetBrains Mono', monospace; - font-size: 13px; - color: var(--text-primary, #e0e0e0); -} - -/* Raw data in modal */ -.signal-details-modal .signal-raw-data { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-secondary, #aaa); - background: var(--bg-tertiary, #1a1a1a); - padding: 12px; - border-radius: 6px; - border: 1px solid var(--border-color, #333); - white-space: pre-wrap; - word-break: break-all; - margin: 0; - max-height: 200px; - overflow-y: auto; -} - -/* Signal assessment panel in modal */ -.signal-details-modal .signal-assessment { - background: var(--bg-secondary, #252525); - padding: 14px; - border-radius: 8px; - margin-bottom: 16px; -} - -.signal-details-modal .signal-assessment-summary { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 12px; -} - -.signal-details-modal .signal-assessment-text { - font-size: 13px; - color: var(--text-secondary, #aaa); - line-height: 1.4; -} - -.signal-details-modal .signal-assessment-caveat { - font-size: 10px; - color: var(--text-dim, #666); - font-style: italic; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--border-color, #333); -} - -/* Signal guess section in modal */ -.signal-details-modal .signal-guess-section { - background: var(--bg-secondary, #252525); - padding: 14px; - border-radius: 8px; - margin-bottom: 16px; - border: none; -} - -.signal-details-modal .signal-guess-content { - margin-top: 8px; -} - -/* Responsive adjustments */ -@media (max-width: 500px) { - .signal-details-modal-content { - width: 95%; - max-height: 90vh; - } - - .signal-details-grid { - grid-template-columns: 1fr; - } -} +/** + * Signal Cards Component System + * Reusable card components for displaying RF signals and decoded messages + * Used across: Pager, APRS, Sensors, and other signal-based modes + */ + +/* ============================================ + STATUS COLORS & VARIABLES + ============================================ */ +:root { + /* Signal status colors */ + --signal-new: #3b82f6; + --signal-new-bg: rgba(59, 130, 246, 0.12); + --signal-baseline: #6b7280; + --signal-baseline-bg: rgba(107, 114, 128, 0.08); + --signal-burst: #f59e0b; + --signal-burst-bg: rgba(245, 158, 11, 0.12); + --signal-repeated: #eab308; + --signal-repeated-bg: rgba(234, 179, 8, 0.10); + --signal-emergency: #ef4444; + --signal-emergency-bg: rgba(239, 68, 68, 0.15); + + /* Protocol colors */ + --proto-pocsag: var(--accent-cyan, #4a9eff); + --proto-flex: var(--accent-amber, #f59e0b); + --proto-aprs: #06b6d4; + --proto-ais: #8b5cf6; + --proto-acars: #ec4899; +} + +/* ============================================ + SIGNAL FEED CONTAINER + ============================================ */ +.signal-feed { + display: flex; + flex-direction: column; + gap: 8px; +} + +.signal-feed-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid var(--border-color); + margin-bottom: 8px; +} + +.signal-feed-title { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.signal-feed-live { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent-green); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.signal-feed-live .live-dot { + width: 8px; + height: 8px; + background: var(--accent-green); + border-radius: 50%; + animation: signalPulse 2s ease-in-out infinite; +} + +@keyframes signalPulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.85); } +} + +/* ============================================ + FILTER BAR + ============================================ */ +.signal-filter-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.signal-filter-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + margin-right: 6px; +} + +.signal-filter-btn { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: 'Inter', sans-serif; + font-size: 11px; + font-weight: 500; + padding: 5px 10px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.signal-filter-btn:hover { + border-color: var(--border-light); + background: var(--bg-elevated); +} + +.signal-filter-btn.active { + border-color: var(--accent-cyan); + background: var(--accent-cyan-dim); + color: var(--accent-cyan); +} + +.signal-filter-btn .filter-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.signal-filter-btn[data-filter="all"] .filter-dot { background: var(--text-secondary); } +.signal-filter-btn[data-filter="emergency"] .filter-dot { background: var(--signal-emergency); } +.signal-filter-btn[data-filter="new"] .filter-dot { background: var(--signal-new); } +.signal-filter-btn[data-filter="burst"] .filter-dot { background: var(--signal-burst); } +.signal-filter-btn[data-filter="repeated"] .filter-dot { background: var(--signal-repeated); } +.signal-filter-btn[data-filter="baseline"] .filter-dot { background: var(--signal-baseline); } + +.signal-filter-count { + font-family: var(--font-mono); + font-size: 10px; + background: var(--bg-secondary); + padding: 1px 5px; + border-radius: 3px; + color: var(--text-dim); +} + +.signal-filter-btn.active .signal-filter-count { + background: rgba(74, 158, 255, 0.2); + color: var(--accent-cyan); +} + +.signal-filter-divider { + width: 1px; + height: 20px; + background: var(--border-color); + margin: 0 6px; +} + +/* ============================================ + BASE SIGNAL CARD + ============================================ */ +.signal-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; + transition: all 0.2s ease; + position: relative; + overflow: hidden; + animation: cardSlideIn 0.25s ease; +} + +@keyframes cardSlideIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.signal-card:hover { + background: var(--bg-elevated); + border-color: var(--border-light); +} + +.signal-card.hidden { + display: none; +} + +/* Left accent border for status */ +.signal-card::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--card-accent, transparent); +} + +/* ============================================ + SIGNAL CARD STATUS VARIANTS + ============================================ */ +.signal-card[data-status="new"] { + --card-accent: var(--signal-new); + background: linear-gradient(90deg, var(--signal-new-bg) 0%, var(--bg-card) 35%); +} + +.signal-card[data-status="burst"] { + --card-accent: var(--signal-burst); + background: linear-gradient(90deg, var(--signal-burst-bg) 0%, var(--bg-card) 35%); +} + +.signal-card[data-status="repeated"] { + --card-accent: var(--signal-repeated); +} + +.signal-card[data-status="baseline"] { + --card-accent: var(--signal-baseline); +} + +.signal-card[data-status="emergency"] { + --card-accent: var(--signal-emergency); + background: linear-gradient(90deg, var(--signal-emergency-bg) 0%, var(--bg-card) 35%); + border-color: rgba(239, 68, 68, 0.3); +} + +/* ============================================ + CARD HEADER + ============================================ */ +.signal-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 10px; +} + +.signal-card-badges { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* Protocol badge */ +.signal-proto-badge { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 3px 7px; + border-radius: 3px; + border: 1px solid; +} + +.signal-proto-badge.pocsag { + background: var(--accent-cyan-dim); + color: var(--accent-cyan); + border-color: rgba(74, 158, 255, 0.25); +} + +.signal-proto-badge.flex { + background: var(--accent-amber-dim); + color: var(--accent-amber); + border-color: rgba(212, 168, 83, 0.25); +} + +.signal-proto-badge.aprs { + background: rgba(6, 182, 212, 0.15); + color: #06b6d4; + border-color: rgba(6, 182, 212, 0.25); +} + +.signal-proto-badge.ais { + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; + border-color: rgba(139, 92, 246, 0.25); +} + +.signal-proto-badge.acars { + background: rgba(236, 72, 153, 0.15); + color: #ec4899; + border-color: rgba(236, 72, 153, 0.25); +} + +/* Frequency/Address badge */ +.signal-freq-badge { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + background: var(--bg-secondary); + padding: 3px 8px; + border-radius: 3px; + border: 1px solid var(--border-color); +} + +/* Status pill */ +.signal-status-pill { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 3px 8px; + border-radius: 10px; +} + +.signal-status-pill .status-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} + +.signal-status-pill[data-status="new"] { + background: var(--signal-new-bg); + color: var(--signal-new); +} + +.signal-status-pill[data-status="baseline"] { + background: var(--signal-baseline-bg); + color: var(--signal-baseline); +} + +.signal-status-pill[data-status="burst"] { + background: var(--signal-burst-bg); + color: var(--signal-burst); +} + +.signal-status-pill[data-status="repeated"] { + background: var(--signal-repeated-bg); + color: var(--signal-repeated); +} + +.signal-status-pill[data-status="emergency"] { + background: var(--signal-emergency-bg); + color: var(--signal-emergency); +} + +.signal-status-pill[data-status="new"] .status-dot, +.signal-status-pill[data-status="burst"] .status-dot, +.signal-status-pill[data-status="emergency"] .status-dot { + animation: signalPulse 1.5s ease-in-out infinite; +} + +/* ============================================ + CARD BODY + ============================================ */ +.signal-card-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Message metadata row */ +.signal-meta-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.signal-sender { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent-green); +} + +.signal-msg-type { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + background: var(--bg-secondary); + padding: 2px 6px; + border-radius: 3px; +} + +.signal-timestamp { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + margin-left: auto; +} + +/* Message content preview */ +.signal-message { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + background: var(--bg-secondary); + padding: 10px; + border-radius: 4px; + border-left: 2px solid var(--border-color); + line-height: 1.5; + word-break: break-word; +} + +.signal-message.numeric { + font-size: 14px; + letter-spacing: 1.5px; +} + +.signal-message.emergency { + border-left-color: var(--signal-emergency); + background: var(--signal-emergency-bg); +} + +.signal-message.truncated::after { + content: '...'; + color: var(--text-dim); +} + +/* Signal strength indicator */ +.signal-strength-row { + display: flex; + align-items: center; + gap: 12px; +} + +.signal-strength-bars { + display: flex; + align-items: flex-end; + gap: 2px; + height: 16px; +} + +.signal-strength-bars .bar { + width: 3px; + background: var(--border-light); + border-radius: 1px; + transition: background 0.2s; +} + +.signal-strength-bars .bar:nth-child(1) { height: 5px; } +.signal-strength-bars .bar:nth-child(2) { height: 8px; } +.signal-strength-bars .bar:nth-child(3) { height: 11px; } +.signal-strength-bars .bar:nth-child(4) { height: 14px; } +.signal-strength-bars .bar:nth-child(5) { height: 16px; } + +.signal-strength-bars .bar.active { + background: var(--accent-green); +} + +.signal-strength-bars .bar.active.weak { + background: var(--accent-orange); +} + +.signal-activity { + font-size: 12px; + color: var(--text-secondary); +} + +/* Behavior tag */ +.signal-behavior { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-dim); + background: var(--bg-secondary); + padding: 5px 8px; + border-radius: 4px; + width: fit-content; +} + +.signal-behavior svg { + width: 12px; + height: 12px; + opacity: 0.7; +} + +/* ============================================ + CARD FOOTER & ACTIONS + ============================================ */ +.signal-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-color); +} + +.signal-advanced-toggle { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-dim); + background: none; + border: none; + cursor: pointer; + padding: 4px 6px; + margin: -4px -6px; + border-radius: 4px; + transition: all 0.15s; +} + +.signal-advanced-toggle:hover { + color: var(--text-secondary); + background: var(--bg-secondary); +} + +.signal-advanced-toggle svg { + width: 12px; + height: 12px; + transition: transform 0.2s; +} + +.signal-advanced-toggle.open svg { + transform: rotate(180deg); +} + +.signal-advanced-toggle.open { + color: var(--accent-cyan); +} + +.signal-card-actions { + display: flex; + gap: 6px; +} + +.signal-action-btn { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + padding: 5px 8px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; +} + +.signal-action-btn:hover { + color: var(--text-secondary); + border-color: var(--border-light); +} + +.signal-action-btn.primary { + background: var(--accent-cyan-dim); + border-color: rgba(74, 158, 255, 0.25); + color: var(--accent-cyan); +} + +.signal-action-btn.primary:hover { + background: rgba(74, 158, 255, 0.2); +} + +.signal-action-btn.danger { + background: var(--accent-red-dim); + border-color: rgba(239, 68, 68, 0.25); + color: var(--accent-red); +} + +.signal-action-btn.danger:hover { + background: rgba(239, 68, 68, 0.2); +} + +/* ============================================ + ADVANCED PANEL + ============================================ */ +.signal-advanced-panel { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.2s ease; + margin-top: 0; +} + +.signal-advanced-panel.open { + grid-template-rows: 1fr; + margin-top: 10px; +} + +.signal-advanced-inner { + overflow: hidden; +} + +.signal-advanced-content { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px; +} + +.signal-advanced-section { + margin-bottom: 10px; +} + +.signal-advanced-section:last-child { + margin-bottom: 0; +} + +.signal-advanced-title { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + margin-bottom: 6px; +} + +.signal-advanced-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} + +.signal-advanced-item { + display: flex; + flex-direction: column; + gap: 1px; +} + +.signal-advanced-label { + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.signal-advanced-value { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-primary); +} + +.signal-raw-data { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-primary); + padding: 8px; + border-radius: 3px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + line-height: 1.5; +} + +.signal-advanced-actions { + display: flex; + gap: 6px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-color); +} + +/* ============================================ + MINI MAP (for APRS/position data) + ============================================ */ +.signal-mini-map { + width: 100%; + height: 70px; + background: var(--bg-secondary); + border-radius: 4px; + border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s; +} + +.signal-mini-map:hover { + border-color: var(--accent-cyan); +} + +.signal-mini-map::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(74, 158, 255, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(74, 158, 255, 0.08) 1px, transparent 1px); + background-size: 14px 14px; +} + +.signal-map-pin { + width: 10px; + height: 10px; + background: var(--signal-emergency); + border-radius: 50%; + border: 2px solid var(--bg-card); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.3); + animation: mapPing 1.5s ease-out infinite; + z-index: 1; +} + +@keyframes mapPing { + 0% { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.4); } + 100% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); } +} + +.signal-map-coords { + position: absolute; + bottom: 4px; + right: 6px; + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + background: var(--bg-card); + padding: 2px 5px; + border-radius: 2px; +} + +/* ============================================ + SPARKLINE CHART + ============================================ */ +.signal-sparkline-container { + margin-top: 6px; +} + +.signal-sparkline-label { + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 3px; +} + +.signal-sparkline { + display: flex; + align-items: flex-end; + gap: 1px; + height: 28px; + padding: 3px; + background: var(--bg-primary); + border-radius: 3px; +} + +.signal-sparkline .spark-bar { + flex: 1; + background: var(--accent-cyan); + border-radius: 1px; + opacity: 0.6; + transition: opacity 0.15s; +} + +.signal-sparkline .spark-bar:hover { + opacity: 1; +} + +.signal-sparkline .spark-bar.spike { + background: var(--signal-burst); + opacity: 0.9; +} + +/* ============================================ + TOAST NOTIFICATION + ============================================ */ +.signal-toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%) translateY(80px); + background: var(--bg-card); + border: 1px solid var(--border-light); + padding: 10px 18px; + border-radius: 6px; + font-size: 12px; + color: var(--text-primary); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4); + z-index: 10000; + opacity: 0; + transition: all 0.25s ease; +} + +.signal-toast.show { + transform: translateX(-50%) translateY(0); + opacity: 1; +} + +.signal-toast.success { + border-color: var(--accent-green); + background: linear-gradient(90deg, var(--accent-green-dim) 0%, var(--bg-card) 40%); +} + +.signal-toast.error { + border-color: var(--accent-red); + background: linear-gradient(90deg, var(--accent-red-dim) 0%, var(--bg-card) 40%); +} + +/* ============================================ + EMPTY STATE + ============================================ */ +.signal-empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-dim); +} + +.signal-empty-state svg { + width: 40px; + height: 40px; + margin-bottom: 12px; + opacity: 0.5; +} + +.signal-empty-state p { + font-size: 13px; +} + +/* ============================================ + COMPACT MODE (for high-density display) + ============================================ */ +.signal-feed.compact .signal-card { + padding: 8px 10px; +} + +.signal-feed.compact .signal-card-header { + margin-bottom: 6px; +} + +.signal-feed.compact .signal-proto-badge { + font-size: 9px; + padding: 2px 5px; +} + +.signal-feed.compact .signal-freq-badge { + font-size: 10px; + padding: 2px 6px; +} + +.signal-feed.compact .signal-message { + font-size: 11px; + padding: 8px; +} + +.signal-feed.compact .signal-card-footer { + margin-top: 8px; + padding-top: 8px; +} + +/* ============================================ + SEARCH INPUT + ============================================ */ +.signal-search-container { + flex: 1; + min-width: 150px; + max-width: 250px; +} + +.signal-search-input { + width: 100%; + padding: 6px 10px; + font-family: 'Inter', sans-serif; + font-size: 11px; + color: var(--text-primary); + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 4px; + outline: none; + transition: all 0.15s; +} + +.signal-search-input::placeholder { + color: var(--text-dim); +} + +.signal-search-input:focus { + border-color: var(--accent-cyan); + background: var(--bg-elevated); +} + +/* ============================================ + SEEN COUNT BADGE + ============================================ */ +.signal-seen-count { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + background: var(--bg-secondary); + padding: 2px 5px; + border-radius: 3px; +} + +/* ============================================ + SENSOR DATA DISPLAY + ============================================ */ +.signal-sensor-data { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px; + background: var(--bg-secondary); + border-radius: 4px; + border-left: 2px solid var(--accent-cyan); +} + +.signal-sensor-reading { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 70px; +} + +.signal-sensor-reading .sensor-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.signal-sensor-reading .sensor-value { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.signal-sensor-reading .sensor-value.low-battery { + color: var(--accent-red); +} + +/* Sensor protocol badge */ +.signal-proto-badge.sensor { + background: var(--accent-green-dim); + color: var(--accent-green); + border-color: rgba(34, 197, 94, 0.25); +} + +/* ============================================ + METER DATA DISPLAY + ============================================ */ +.signal-meter-data { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 12px; + background: var(--bg-secondary); + border-radius: 4px; + border-left: 2px solid var(--accent-yellow); +} + +.signal-meter-reading { + display: flex; + flex-direction: column; + gap: 3px; +} + +.signal-meter-reading .meter-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.signal-meter-reading .meter-value { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +/* Meter protocol badges */ +.signal-proto-badge.meter { + background: rgba(234, 179, 8, 0.15); + color: #eab308; + border-color: rgba(234, 179, 8, 0.25); +} + +.signal-proto-badge.meter.electric { + background: rgba(234, 179, 8, 0.15); + color: #eab308; + border-color: rgba(234, 179, 8, 0.25); +} + +.signal-proto-badge.meter.gas { + background: rgba(249, 115, 22, 0.15); + color: #f97316; + border-color: rgba(249, 115, 22, 0.25); +} + +.signal-proto-badge.meter.water { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border-color: rgba(59, 130, 246, 0.25); +} + +/* ============================================ + AGGREGATED METER CARD + ============================================ */ +.signal-card.meter-aggregated { + /* Inherit standard signal-card styles */ +} + +.meter-aggregated-grid { + display: grid; + grid-template-columns: 1fr 1.2fr 0.8fr; + gap: 12px; + padding: 12px; + background: var(--bg-secondary); + border-radius: 4px; + border-left: 2px solid var(--accent-yellow); + align-items: start; +} + +.meter-aggregated-col { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; /* Allow column to shrink in grid */ + overflow: hidden; +} + +.meter-aggregated-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.meter-aggregated-value { + font-family: var(--font-mono); + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Consumption column */ +.consumption-col .consumption-value { + font-size: 18px; + line-height: 1.2; +} + +/* Delta badge */ +.meter-delta { + font-family: var(--font-mono); + font-size: 11px; + padding: 2px 6px; + border-radius: 3px; + width: fit-content; + background: var(--bg-tertiary, rgba(255, 255, 255, 0.05)); + color: var(--text-dim); +} + +.meter-delta.positive { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.meter-delta.negative { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +/* Sparkline container */ +.meter-sparkline-container { + min-height: 28px; + display: flex; + align-items: center; + max-width: 100%; + overflow: hidden; +} + +.meter-sparkline-placeholder { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); +} + +/* Rate display */ +.meter-rate-value { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + color: var(--accent-cyan, #4a9eff); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Update animation */ +.signal-card.meter-updated { + animation: meterUpdatePulse 0.3s ease; +} + +@keyframes meterUpdatePulse { + 0% { + box-shadow: 0 0 0 0 rgba(234, 179, 8, 0.4); + } + 50% { + box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.2); + } + 100% { + box-shadow: 0 0 0 0 rgba(234, 179, 8, 0); + } +} + +/* Consumption sparkline styles */ +.consumption-sparkline-svg { + display: block; +} + +.consumption-sparkline-wrapper { + display: flex; + align-items: center; + gap: 6px; +} + +.consumption-trend { + font-size: 14px; + font-weight: 500; +} + +/* Responsive adjustments for aggregated meters */ +@media (max-width: 500px) { + .meter-aggregated-grid { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + } + + .meter-aggregated-col.trend-col { + grid-column: 1 / -1; + } + + .meter-sparkline-container { + width: 100%; + } + + .meter-sparkline-container svg { + width: 100%; + } +} + +/* ============================================ + APRS SYMBOL + ============================================ */ +.signal-aprs-symbol { + font-size: 12px; + padding: 2px 4px; + background: var(--bg-secondary); + border-radius: 3px; +} + +/* ============================================ + DISTANCE DISPLAY + ============================================ */ +.signal-distance { + font-family: var(--font-mono); + font-size: 10px; + color: var(--accent-green); + font-weight: 500; +} + +/* ============================================ + COMPACT CARD VARIANT + For constrained layouts like APRS station list + ============================================ */ +.signal-card-compact { + padding: 10px 12px; +} + +.signal-card-compact .signal-card-header { + margin-bottom: 6px; +} + +.signal-card-compact .signal-proto-badge { + font-size: 9px; + padding: 2px 5px; +} + +.signal-card-compact .signal-freq-badge { + font-size: 10px; + padding: 2px 6px; +} + +.signal-card-compact .signal-status-pill { + font-size: 9px; + padding: 2px 6px; +} + +.signal-card-compact .signal-message { + font-size: 11px; + padding: 6px 8px; + max-height: 40px; +} + +.signal-card-compact .signal-meta-row { + font-size: 9px; +} + +.signal-card-compact .signal-mini-map { + padding: 6px 8px; + font-size: 10px; +} + +.signal-card-compact .signal-card-footer { + margin-top: 6px; + padding-top: 6px; +} + +.signal-card-compact .signal-advanced-toggle { + font-size: 9px; + padding: 3px 6px; +} + +/* Compact filter bar for APRS */ +.signal-filter-bar-compact { + padding: 6px 8px; + margin-bottom: 8px; + gap: 4px; +} + +.signal-filter-bar-compact .signal-filter-btn { + padding: 3px 6px; + font-size: 9px; +} + +.signal-filter-bar-compact .signal-filter-count { + font-size: 8px; + padding: 1px 3px; + min-width: 14px; +} + +.signal-filter-bar-compact .signal-search-input { + padding: 4px 8px; + font-size: 10px; +} + +.signal-filter-bar-compact .signal-filter-divider { + margin: 0 4px; +} + +/* ============================================ + TONE ONLY MESSAGE STYLING + ============================================ */ +.signal-message.tone-only { + color: var(--text-dim); + font-style: italic; + border-left-color: var(--border-color); +} + +/* ============================================ + FILTER BAR RESPONSIVE + ============================================ */ +@media (max-width: 768px) { + .signal-filter-bar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .signal-filter-bar > .signal-filter-label { + margin-top: 8px; + } + + .signal-filter-bar > .signal-filter-label:first-child { + margin-top: 0; + } + + .signal-filter-divider { + display: none; + } + + .signal-search-container { + max-width: none; + } + + .signal-filter-bar .signal-filter-btn { + flex: 1; + } +} + +/* ============================================ + SIGNAL STRENGTH INDICATOR + Classification-based signal display + ============================================ */ +.signal-strength-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + background: var(--bg-secondary); + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.signal-strength-indicator.compact { + padding: 2px 4px; + gap: 0; + background: transparent; + border: none; +} + +.signal-strength-indicator.no-data { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + opacity: 0.5; +} + +.signal-strength-bars { + display: inline-block; + vertical-align: middle; +} + +.signal-strength-bars rect { + transition: fill 0.2s ease; +} + +.signal-strength-label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* Confidence-based styling */ +.signal-confidence-low { + opacity: 0.7; +} + +.signal-confidence-medium { + opacity: 0.85; +} + +.signal-confidence-high { + opacity: 1; +} + +.signal-advanced-value.signal-confidence-low { + color: var(--text-dim); +} + +.signal-advanced-value.signal-confidence-medium { + color: var(--accent-amber); +} + +.signal-advanced-value.signal-confidence-high { + color: var(--accent-green); +} + +/* ============================================ + SIGNAL ASSESSMENT PANEL + Detailed signal analysis in advanced panel + ============================================ */ +.signal-assessment { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px; + margin-bottom: 12px; +} + +.signal-assessment-summary { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.signal-assessment-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + flex: 1; +} + +.signal-assessment-caveat { + font-size: 10px; + color: var(--text-dim); + font-style: italic; + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed var(--border-color); + line-height: 1.4; +} + +/* Signal assessment confidence badges */ +.signal-assessment .signal-advanced-grid { + margin-top: 8px; +} + +/* Range estimate styling */ +.signal-range-estimate { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 11px; +} + +.signal-range-estimate .range-value { + color: var(--accent-cyan); + font-weight: 500; +} + +.signal-range-estimate .range-disclaimer { + font-size: 9px; + color: var(--text-dim); + font-style: italic; +} + +/* ============================================ + Clickable Station Badge (APRS) + ============================================ */ + +.signal-station-clickable { + cursor: pointer; + transition: all 0.15s ease; + position: relative; +} + +.signal-station-clickable:hover { + background: var(--accent-purple); + color: #000; + transform: scale(1.05); + box-shadow: 0 0 8px rgba(138, 43, 226, 0.4); +} + +.signal-station-clickable:active { + transform: scale(0.98); +} + +.signal-station-clickable::after { + content: ''; + position: absolute; + bottom: -2px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 2px; + background: var(--accent-purple); + transition: width 0.2s ease; +} + +.signal-station-clickable:hover::after { + width: 80%; +} + +/* ============================================ + Station Raw Data Modal + ============================================ */ + +.station-raw-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; +} + +.station-raw-modal.show { + opacity: 1; + visibility: visible; +} + +.station-raw-modal-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} + +.station-raw-modal-content { + position: relative; + background: var(--panel-bg, #1a1a2e); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + transform: scale(0.95); + transition: transform 0.2s ease; +} + +.station-raw-modal.show .station-raw-modal-content { + transform: scale(1); +} + +.station-raw-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color, #333); + background: rgba(0, 0, 0, 0.2); + border-radius: 8px 8px 0 0; +} + +.station-raw-modal-title { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--accent-cyan, #00d4ff); +} + +.station-raw-modal-close { + background: none; + border: none; + color: var(--text-muted, #888); + font-size: 24px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + transition: color 0.15s ease; +} + +.station-raw-modal-close:hover { + color: var(--accent-red, #ff4444); +} + +.station-raw-modal-body { + padding: 16px; + overflow-y: auto; + flex: 1; +} + +.station-raw-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted, #888); + margin-bottom: 8px; +} + +.station-raw-data-display { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + color: var(--accent-green, #00ff88); + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + padding: 12px; + word-break: break-all; + white-space: pre-wrap; + margin: 0; + max-height: 300px; + overflow-y: auto; +} + +.station-raw-modal-footer { + display: flex; + justify-content: flex-end; + padding: 12px 16px; + border-top: 1px solid var(--border-color, #333); + background: rgba(0, 0, 0, 0.2); + border-radius: 0 0 8px 8px; +} + +.station-raw-copy-btn { + font-family: var(--font-mono); + font-size: 11px; + padding: 8px 16px; + background: var(--accent-purple, #8a2be2); + border: none; + border-radius: 4px; + color: #fff; + cursor: pointer; + transition: all 0.15s ease; +} + +.station-raw-copy-btn:hover { + background: var(--accent-cyan, #00d4ff); + color: #000; +} + +/* ============================================ + SIGNAL GUESS INTEGRATION + ============================================ */ + +/* Signal guess badge in card header */ +.signal-card-badges .signal-guess-badge { + margin-left: 4px; +} + +/* Signal guess section in advanced panel */ +.signal-guess-section { + border-bottom: 1px solid var(--border-color); + padding-bottom: 12px; + margin-bottom: 12px; +} + +.signal-guess-section .signal-guess-container { + margin-top: 8px; +} + +/* Adjust guess label colors for dark theme */ +.signal-guess-section .signal-guess-label { + color: var(--text-primary, #e0e0e0); +} + +.signal-guess-section .signal-guess-tag { + background: var(--bg-tertiary, #2a2a2a); + color: var(--text-secondary, #888); +} + +.signal-guess-section .signal-guess-alt-item { + color: var(--text-secondary, #999); +} + +.signal-guess-section .signal-guess-popup-explanation { + color: var(--text-secondary, #aaa); +} + +/* ============================================ + CLICKABLE CARDS + ============================================ */ +.signal-card.signal-card-clickable { + cursor: pointer; + transition: all 0.15s ease; +} + +.signal-card.signal-card-clickable:hover { + border-color: var(--accent-cyan, #00d4ff); + box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2); +} + +.signal-card.signal-card-clickable:active { + transform: scale(0.995); +} + +/* Floating action buttons for clickable cards */ +.signal-card-actions-float { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 6px; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 2; +} + +.signal-card.signal-card-clickable:hover .signal-card-actions-float { + opacity: 1; +} + +.signal-card-actions-float .signal-action-btn { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; +} + +.signal-card-actions-float .signal-action-btn:hover { + color: var(--text-secondary); + border-color: var(--border-light); + background: var(--bg-tertiary); +} + +/* ============================================ + SIGNAL DETAILS MODAL + ============================================ */ +.signal-details-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; +} + +.signal-details-modal.show { + opacity: 1; + visibility: visible; +} + +.signal-details-modal-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} + +.signal-details-modal-content { + position: relative; + background: var(--panel-bg, #1a1a2e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + width: 90%; + max-width: 600px; + max-height: 85vh; + display: flex; + flex-direction: column; + transform: scale(0.95); + transition: transform 0.2s ease; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.signal-details-modal.show .signal-details-modal-content { + transform: scale(1); +} + +.signal-details-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #333); + background: rgba(0, 0, 0, 0.2); + border-radius: 12px 12px 0 0; +} + +.signal-details-modal-title { + font-family: var(--font-mono); + font-size: 15px; + font-weight: 600; + color: var(--accent-cyan, #00d4ff); +} + +.signal-details-modal-close { + background: none; + border: none; + color: var(--text-muted, #888); + font-size: 24px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: color 0.15s ease; +} + +.signal-details-modal-close:hover { + color: var(--accent-red, #ff4444); +} + +.signal-details-modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.signal-details-modal-footer { + display: flex; + justify-content: flex-end; + padding: 12px 20px; + border-top: 1px solid var(--border-color, #333); + background: rgba(0, 0, 0, 0.2); + border-radius: 0 0 12px 12px; +} + +.signal-details-copy-btn { + font-family: var(--font-mono); + font-size: 11px; + padding: 8px 16px; + background: var(--accent-purple, #8a2be2); + border: none; + border-radius: 4px; + color: #fff; + cursor: pointer; + transition: all 0.15s ease; +} + +.signal-details-copy-btn:hover { + background: var(--accent-cyan, #00d4ff); + color: #000; +} + +/* Signal Details Content Sections */ +.signal-details-section { + margin-bottom: 20px; +} + +.signal-details-section:last-child { + margin-bottom: 0; +} + +.signal-details-title { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim, #666); + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-color, #333); +} + +.signal-details-message { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-primary, #e0e0e0); + background: var(--bg-secondary, #252525); + padding: 12px 14px; + border-radius: 6px; + word-break: break-word; + line-height: 1.5; +} + +.signal-details-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.signal-details-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.signal-details-label { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim, #666); +} + +.signal-details-value { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-primary, #e0e0e0); +} + +/* Raw data in modal */ +.signal-details-modal .signal-raw-data { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary, #aaa); + background: var(--bg-tertiary, #1a1a1a); + padding: 12px; + border-radius: 6px; + border: 1px solid var(--border-color, #333); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + max-height: 200px; + overflow-y: auto; +} + +/* Signal assessment panel in modal */ +.signal-details-modal .signal-assessment { + background: var(--bg-secondary, #252525); + padding: 14px; + border-radius: 8px; + margin-bottom: 16px; +} + +.signal-details-modal .signal-assessment-summary { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.signal-details-modal .signal-assessment-text { + font-size: 13px; + color: var(--text-secondary, #aaa); + line-height: 1.4; +} + +.signal-details-modal .signal-assessment-caveat { + font-size: 10px; + color: var(--text-dim, #666); + font-style: italic; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-color, #333); +} + +/* Signal guess section in modal */ +.signal-details-modal .signal-guess-section { + background: var(--bg-secondary, #252525); + padding: 14px; + border-radius: 8px; + margin-bottom: 16px; + border: none; +} + +.signal-details-modal .signal-guess-content { + margin-top: 8px; +} + +/* Responsive adjustments */ +@media (max-width: 500px) { + .signal-details-modal-content { + width: 95%; + max-height: 90vh; + } + + .signal-details-grid { + grid-template-columns: 1fr; + } +} diff --git a/static/css/components/signal-timeline.css b/static/css/components/signal-timeline.css index 2009464..8c1ddcc 100644 --- a/static/css/components/signal-timeline.css +++ b/static/css/components/signal-timeline.css @@ -1,577 +1,577 @@ -/** - * Signal Activity Timeline Component - * Lightweight visualization for RF signal presence over time - * Used for TSCM sweeps and investigative analysis - */ - -/* ============================================ - TIMELINE CONTAINER - ============================================ */ -.signal-timeline { - background: var(--bg-card, #1a1a1a); - border: 1px solid var(--border-color, #333); - border-radius: 6px; - font-family: 'JetBrains Mono', monospace; -} - -.signal-timeline.collapsed .signal-timeline-body { - display: none; -} - -.signal-timeline.collapsed .signal-timeline-header { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; -} - -.signal-timeline-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - cursor: pointer; - user-select: none; -} - -.signal-timeline-header:hover { - background: rgba(255, 255, 255, 0.02); -} - -.signal-timeline-body { - padding: 0 12px 12px 12px; - border-top: 1px solid var(--border-color, #333); -} - -.signal-timeline-collapse-icon { - margin-right: 8px; - font-size: 10px; - transition: transform 0.2s ease; -} - -.signal-timeline.collapsed .signal-timeline-collapse-icon { - transform: rotate(-90deg); -} - -.signal-timeline-header-stats { - display: flex; - gap: 12px; - font-size: 10px; - color: var(--text-dim, #666); -} - -.signal-timeline-header-stat { - display: flex; - align-items: center; - gap: 4px; -} - -.signal-timeline-header-stat .stat-value { - color: var(--text-primary, #fff); - font-weight: 500; -} - -.signal-timeline-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary, #888); -} - -.signal-timeline-controls { - display: flex; - gap: 6px; - align-items: center; -} - -.signal-timeline-btn { - background: var(--bg-secondary, #252525); - border: 1px solid var(--border-color, #333); - color: var(--text-secondary, #888); - font-size: 9px; - padding: 4px 8px; - border-radius: 3px; - cursor: pointer; - transition: all 0.15s ease; - font-family: inherit; -} - -.signal-timeline-btn:hover { - background: var(--bg-elevated, #2a2a2a); - color: var(--text-primary, #fff); -} - -.signal-timeline-btn.active { - background: var(--accent-cyan, #4a9eff); - color: #000; - border-color: var(--accent-cyan, #4a9eff); -} - -/* Time window selector */ -.signal-timeline-window { - display: flex; - align-items: center; - gap: 4px; - font-size: 9px; - color: var(--text-dim, #666); -} - -.signal-timeline-window select { - background: var(--bg-secondary, #252525); - border: 1px solid var(--border-color, #333); - color: var(--text-primary, #fff); - font-size: 9px; - padding: 3px 6px; - border-radius: 3px; - font-family: inherit; -} - -/* ============================================ - TIME AXIS - ============================================ */ -.signal-timeline-axis { - display: flex; - justify-content: space-between; - padding: 0 80px 0 100px; - margin-bottom: 8px; - font-size: 9px; - color: var(--text-dim, #666); -} - -.signal-timeline-axis-label { - position: relative; -} - -.signal-timeline-axis-label::before { - content: ''; - position: absolute; - top: -4px; - left: 50%; - width: 1px; - height: 4px; - background: var(--border-color, #333); -} - -/* ============================================ - SWIMLANES - ============================================ */ -.signal-timeline-lanes { - display: flex; - flex-direction: column; - gap: 3px; - max-height: 160px; - overflow-y: auto; - margin-top: 8px; -} - -.signal-timeline-lanes::-webkit-scrollbar { - width: 6px; -} - -.signal-timeline-lanes::-webkit-scrollbar-track { - background: var(--bg-secondary, #252525); - border-radius: 3px; -} - -.signal-timeline-lanes::-webkit-scrollbar-thumb { - background: var(--border-color, #444); - border-radius: 3px; -} - -.signal-timeline-lanes::-webkit-scrollbar-thumb:hover { - background: var(--text-dim, #666); -} - -.signal-timeline-lane { - display: flex; - align-items: stretch; - min-height: 36px; - background: var(--bg-secondary, #252525); - border-radius: 3px; - overflow: hidden; -} - -.signal-timeline-lane:hover { - background: var(--bg-elevated, #2a2a2a); -} - -.signal-timeline-lane.expanded { - min-height: auto; -} - -.signal-timeline-lane.baseline { - opacity: 0.5; -} - -.signal-timeline-lane.baseline:hover { - opacity: 0.8; -} - -/* Signal label */ -.signal-timeline-label { - width: 130px; - min-width: 130px; - padding: 6px 8px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 1px; - border-right: 1px solid var(--border-color, #333); - overflow: hidden; -} - -.signal-timeline-freq { - color: var(--text-primary, #fff); - font-size: 11px; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.2; -} - -.signal-timeline-name { - color: var(--text-dim, #666); - font-size: 9px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.2; -} - -/* Status indicator */ -.signal-timeline-status { - width: 4px; - min-width: 4px; -} - -.signal-timeline-status[data-status="new"] { - background: var(--signal-new, #3b82f6); -} - -.signal-timeline-status[data-status="baseline"] { - background: var(--signal-baseline, #6b7280); -} - -.signal-timeline-status[data-status="burst"] { - background: var(--signal-burst, #f59e0b); -} - -.signal-timeline-status[data-status="flagged"] { - background: var(--signal-emergency, #ef4444); -} - -.signal-timeline-status[data-status="gone"] { - background: var(--text-dim, #666); -} - -/* ============================================ - TRACK (where bars are drawn) - ============================================ */ -.signal-timeline-track { - flex: 1; - position: relative; - height: 100%; - min-height: 36px; - padding: 4px 8px; - cursor: pointer; -} - -.signal-timeline-track-bg { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; -} - -/* Grid lines */ -.signal-timeline-grid { - position: absolute; - top: 0; - bottom: 0; - width: 1px; - background: var(--border-color, #333); - opacity: 0.3; -} - -/* ============================================ - SIGNAL BARS - ============================================ */ -.signal-timeline-bar { - position: absolute; - top: 50%; - transform: translateY(-50%); - height: 16px; - min-width: 2px; - border-radius: 2px; - transition: opacity 0.15s ease; -} - -/* Strength variants (height) */ -.signal-timeline-bar[data-strength="1"] { height: 6px; } -.signal-timeline-bar[data-strength="2"] { height: 10px; } -.signal-timeline-bar[data-strength="3"] { height: 14px; } -.signal-timeline-bar[data-strength="4"] { height: 18px; } -.signal-timeline-bar[data-strength="5"] { height: 22px; } - -/* Status colors */ -.signal-timeline-bar[data-status="new"] { - background: var(--signal-new, #3b82f6); - box-shadow: 0 0 6px rgba(59, 130, 246, 0.4); -} - -.signal-timeline-bar[data-status="baseline"] { - background: var(--signal-baseline, #6b7280); -} - -.signal-timeline-bar[data-status="burst"] { - background: var(--signal-burst, #f59e0b); - box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); -} - -.signal-timeline-bar[data-status="flagged"] { - background: var(--signal-emergency, #ef4444); - box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); - animation: flaggedPulse 2s ease-in-out infinite; -} - -@keyframes flaggedPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } -} - -.signal-timeline-lane:hover .signal-timeline-bar { - opacity: 0.9; -} - -/* ============================================ - EXPANDED VIEW (tick marks) - ============================================ */ -.signal-timeline-ticks { - display: none; - position: relative; - height: 24px; - margin-top: 4px; - border-top: 1px solid var(--border-color, #333); - padding-top: 4px; -} - -.signal-timeline-lane.expanded .signal-timeline-ticks { - display: block; -} - -.signal-timeline-tick { - position: absolute; - bottom: 0; - width: 1px; - background: var(--accent-cyan, #4a9eff); -} - -.signal-timeline-tick[data-strength="1"] { height: 4px; } -.signal-timeline-tick[data-strength="2"] { height: 8px; } -.signal-timeline-tick[data-strength="3"] { height: 12px; } -.signal-timeline-tick[data-strength="4"] { height: 16px; } -.signal-timeline-tick[data-strength="5"] { height: 20px; } - -/* ============================================ - ANNOTATIONS - ============================================ */ -.signal-timeline-annotations { - margin-top: 6px; - padding-top: 6px; - border-top: 1px solid var(--border-color, #333); - max-height: 60px; - overflow-y: auto; -} - -.signal-timeline-annotation { - padding: 3px 6px; - font-size: 9px; - margin-bottom: 2px; -} - -.signal-timeline-annotation { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - font-size: 10px; - color: var(--text-secondary, #888); - background: var(--bg-secondary, #252525); - border-radius: 3px; - margin-bottom: 4px; -} - -.signal-timeline-annotation-icon { - font-size: 12px; -} - -.signal-timeline-annotation[data-type="new"] { - border-left: 2px solid var(--signal-new, #3b82f6); -} - -.signal-timeline-annotation[data-type="burst"] { - border-left: 2px solid var(--signal-burst, #f59e0b); -} - -.signal-timeline-annotation[data-type="pattern"] { - border-left: 2px solid var(--accent-cyan, #4a9eff); -} - -.signal-timeline-annotation[data-type="flagged"] { - border-left: 2px solid var(--signal-emergency, #ef4444); - color: var(--signal-emergency, #ef4444); -} - -/* ============================================ - TOOLTIP - ============================================ */ -.signal-timeline-tooltip { - position: fixed; - z-index: 1000; - background: var(--bg-elevated, #2a2a2a); - border: 1px solid var(--border-color, #333); - border-radius: 4px; - padding: 8px 10px; - font-size: 10px; - color: var(--text-primary, #fff); - pointer-events: none; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - max-width: 220px; -} - -.signal-timeline-tooltip-header { - font-weight: 600; - margin-bottom: 4px; - color: var(--accent-cyan, #4a9eff); -} - -.signal-timeline-tooltip-row { - display: flex; - justify-content: space-between; - gap: 12px; - color: var(--text-secondary, #888); -} - -.signal-timeline-tooltip-row span:last-child { - color: var(--text-primary, #fff); -} - -/* ============================================ - STATS ROW - ============================================ */ -.signal-timeline-stats { - width: 50px; - min-width: 50px; - padding: 4px 6px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-end; - font-size: 9px; - color: var(--text-dim, #666); - border-left: 1px solid var(--border-color, #333); -} - -.signal-timeline-stat-count { - color: var(--text-primary, #fff); - font-weight: 500; -} - -.signal-timeline-stat-label { - font-size: 8px; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -/* ============================================ - EMPTY STATE - ============================================ */ -.signal-timeline-empty { - text-align: center; - padding: 30px 20px; - color: var(--text-dim, #666); - font-size: 11px; -} - -.signal-timeline-empty-icon { - font-size: 24px; - margin-bottom: 8px; - opacity: 0.5; -} - -/* ============================================ - LEGEND - compact inline version - ============================================ */ -.signal-timeline-legend { - display: none; /* Hide by default - status colors are self-explanatory */ -} - -.signal-timeline-legend-item { - display: flex; - align-items: center; - gap: 3px; -} - -.signal-timeline-legend-dot { - width: 6px; - height: 6px; - border-radius: 2px; -} - -.signal-timeline-legend-dot.new { background: var(--signal-new, #3b82f6); } -.signal-timeline-legend-dot.baseline { background: var(--signal-baseline, #6b7280); } -.signal-timeline-legend-dot.burst { background: var(--signal-burst, #f59e0b); } -.signal-timeline-legend-dot.flagged { background: var(--signal-emergency, #ef4444); } - -/* ============================================ - NOW MARKER - ============================================ */ -.signal-timeline-now { - position: absolute; - top: 0; - bottom: 0; - width: 2px; - background: var(--accent-green, #22c55e); - z-index: 5; -} - -.signal-timeline-now::after { - content: 'NOW'; - position: absolute; - top: -14px; - left: 50%; - transform: translateX(-50%); - font-size: 8px; - color: var(--accent-green, #22c55e); - font-weight: 600; -} - -/* ============================================ - MARKER (first seen indicator) - ============================================ */ -.signal-timeline-marker { - position: absolute; - top: 50%; - transform: translate(-50%, -50%); - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-bottom: 8px solid var(--signal-new, #3b82f6); - z-index: 4; -} - -.signal-timeline-marker::after { - content: attr(data-label); - position: absolute; - top: 10px; - left: 50%; - transform: translateX(-50%); - font-size: 8px; - color: var(--signal-new, #3b82f6); - white-space: nowrap; -} +/** + * Signal Activity Timeline Component + * Lightweight visualization for RF signal presence over time + * Used for TSCM sweeps and investigative analysis + */ + +/* ============================================ + TIMELINE CONTAINER + ============================================ */ +.signal-timeline { + background: var(--bg-card, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 6px; + font-family: var(--font-mono); +} + +.signal-timeline.collapsed .signal-timeline-body { + display: none; +} + +.signal-timeline.collapsed .signal-timeline-header { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.signal-timeline-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + user-select: none; +} + +.signal-timeline-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.signal-timeline-body { + padding: 0 12px 12px 12px; + border-top: 1px solid var(--border-color, #333); +} + +.signal-timeline-collapse-icon { + margin-right: 8px; + font-size: 10px; + transition: transform 0.2s ease; +} + +.signal-timeline.collapsed .signal-timeline-collapse-icon { + transform: rotate(-90deg); +} + +.signal-timeline-header-stats { + display: flex; + gap: 12px; + font-size: 10px; + color: var(--text-dim, #666); +} + +.signal-timeline-header-stat { + display: flex; + align-items: center; + gap: 4px; +} + +.signal-timeline-header-stat .stat-value { + color: var(--text-primary, #fff); + font-weight: 500; +} + +.signal-timeline-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary, #888); +} + +.signal-timeline-controls { + display: flex; + gap: 6px; + align-items: center; +} + +.signal-timeline-btn { + background: var(--bg-secondary, #252525); + border: 1px solid var(--border-color, #333); + color: var(--text-secondary, #888); + font-size: 9px; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; +} + +.signal-timeline-btn:hover { + background: var(--bg-elevated, #2a2a2a); + color: var(--text-primary, #fff); +} + +.signal-timeline-btn.active { + background: var(--accent-cyan, #4a9eff); + color: #000; + border-color: var(--accent-cyan, #4a9eff); +} + +/* Time window selector */ +.signal-timeline-window { + display: flex; + align-items: center; + gap: 4px; + font-size: 9px; + color: var(--text-dim, #666); +} + +.signal-timeline-window select { + background: var(--bg-secondary, #252525); + border: 1px solid var(--border-color, #333); + color: var(--text-primary, #fff); + font-size: 9px; + padding: 3px 6px; + border-radius: 3px; + font-family: inherit; +} + +/* ============================================ + TIME AXIS + ============================================ */ +.signal-timeline-axis { + display: flex; + justify-content: space-between; + padding: 0 80px 0 100px; + margin-bottom: 8px; + font-size: 9px; + color: var(--text-dim, #666); +} + +.signal-timeline-axis-label { + position: relative; +} + +.signal-timeline-axis-label::before { + content: ''; + position: absolute; + top: -4px; + left: 50%; + width: 1px; + height: 4px; + background: var(--border-color, #333); +} + +/* ============================================ + SWIMLANES + ============================================ */ +.signal-timeline-lanes { + display: flex; + flex-direction: column; + gap: 3px; + max-height: 160px; + overflow-y: auto; + margin-top: 8px; +} + +.signal-timeline-lanes::-webkit-scrollbar { + width: 6px; +} + +.signal-timeline-lanes::-webkit-scrollbar-track { + background: var(--bg-secondary, #252525); + border-radius: 3px; +} + +.signal-timeline-lanes::-webkit-scrollbar-thumb { + background: var(--border-color, #444); + border-radius: 3px; +} + +.signal-timeline-lanes::-webkit-scrollbar-thumb:hover { + background: var(--text-dim, #666); +} + +.signal-timeline-lane { + display: flex; + align-items: stretch; + min-height: 36px; + background: var(--bg-secondary, #252525); + border-radius: 3px; + overflow: hidden; +} + +.signal-timeline-lane:hover { + background: var(--bg-elevated, #2a2a2a); +} + +.signal-timeline-lane.expanded { + min-height: auto; +} + +.signal-timeline-lane.baseline { + opacity: 0.5; +} + +.signal-timeline-lane.baseline:hover { + opacity: 0.8; +} + +/* Signal label */ +.signal-timeline-label { + width: 130px; + min-width: 130px; + padding: 6px 8px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + border-right: 1px solid var(--border-color, #333); + overflow: hidden; +} + +.signal-timeline-freq { + color: var(--text-primary, #fff); + font-size: 11px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +.signal-timeline-name { + color: var(--text-dim, #666); + font-size: 9px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +/* Status indicator */ +.signal-timeline-status { + width: 4px; + min-width: 4px; +} + +.signal-timeline-status[data-status="new"] { + background: var(--signal-new, #3b82f6); +} + +.signal-timeline-status[data-status="baseline"] { + background: var(--signal-baseline, #6b7280); +} + +.signal-timeline-status[data-status="burst"] { + background: var(--signal-burst, #f59e0b); +} + +.signal-timeline-status[data-status="flagged"] { + background: var(--signal-emergency, #ef4444); +} + +.signal-timeline-status[data-status="gone"] { + background: var(--text-dim, #666); +} + +/* ============================================ + TRACK (where bars are drawn) + ============================================ */ +.signal-timeline-track { + flex: 1; + position: relative; + height: 100%; + min-height: 36px; + padding: 4px 8px; + cursor: pointer; +} + +.signal-timeline-track-bg { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; +} + +/* Grid lines */ +.signal-timeline-grid { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--border-color, #333); + opacity: 0.3; +} + +/* ============================================ + SIGNAL BARS + ============================================ */ +.signal-timeline-bar { + position: absolute; + top: 50%; + transform: translateY(-50%); + height: 16px; + min-width: 2px; + border-radius: 2px; + transition: opacity 0.15s ease; +} + +/* Strength variants (height) */ +.signal-timeline-bar[data-strength="1"] { height: 6px; } +.signal-timeline-bar[data-strength="2"] { height: 10px; } +.signal-timeline-bar[data-strength="3"] { height: 14px; } +.signal-timeline-bar[data-strength="4"] { height: 18px; } +.signal-timeline-bar[data-strength="5"] { height: 22px; } + +/* Status colors */ +.signal-timeline-bar[data-status="new"] { + background: var(--signal-new, #3b82f6); + box-shadow: 0 0 6px rgba(59, 130, 246, 0.4); +} + +.signal-timeline-bar[data-status="baseline"] { + background: var(--signal-baseline, #6b7280); +} + +.signal-timeline-bar[data-status="burst"] { + background: var(--signal-burst, #f59e0b); + box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); +} + +.signal-timeline-bar[data-status="flagged"] { + background: var(--signal-emergency, #ef4444); + box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); + animation: flaggedPulse 2s ease-in-out infinite; +} + +@keyframes flaggedPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.signal-timeline-lane:hover .signal-timeline-bar { + opacity: 0.9; +} + +/* ============================================ + EXPANDED VIEW (tick marks) + ============================================ */ +.signal-timeline-ticks { + display: none; + position: relative; + height: 24px; + margin-top: 4px; + border-top: 1px solid var(--border-color, #333); + padding-top: 4px; +} + +.signal-timeline-lane.expanded .signal-timeline-ticks { + display: block; +} + +.signal-timeline-tick { + position: absolute; + bottom: 0; + width: 1px; + background: var(--accent-cyan, #4a9eff); +} + +.signal-timeline-tick[data-strength="1"] { height: 4px; } +.signal-timeline-tick[data-strength="2"] { height: 8px; } +.signal-timeline-tick[data-strength="3"] { height: 12px; } +.signal-timeline-tick[data-strength="4"] { height: 16px; } +.signal-timeline-tick[data-strength="5"] { height: 20px; } + +/* ============================================ + ANNOTATIONS + ============================================ */ +.signal-timeline-annotations { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--border-color, #333); + max-height: 60px; + overflow-y: auto; +} + +.signal-timeline-annotation { + padding: 3px 6px; + font-size: 9px; + margin-bottom: 2px; +} + +.signal-timeline-annotation { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-size: 10px; + color: var(--text-secondary, #888); + background: var(--bg-secondary, #252525); + border-radius: 3px; + margin-bottom: 4px; +} + +.signal-timeline-annotation-icon { + font-size: 12px; +} + +.signal-timeline-annotation[data-type="new"] { + border-left: 2px solid var(--signal-new, #3b82f6); +} + +.signal-timeline-annotation[data-type="burst"] { + border-left: 2px solid var(--signal-burst, #f59e0b); +} + +.signal-timeline-annotation[data-type="pattern"] { + border-left: 2px solid var(--accent-cyan, #4a9eff); +} + +.signal-timeline-annotation[data-type="flagged"] { + border-left: 2px solid var(--signal-emergency, #ef4444); + color: var(--signal-emergency, #ef4444); +} + +/* ============================================ + TOOLTIP + ============================================ */ +.signal-timeline-tooltip { + position: fixed; + z-index: 1000; + background: var(--bg-elevated, #2a2a2a); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + padding: 8px 10px; + font-size: 10px; + color: var(--text-primary, #fff); + pointer-events: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + max-width: 220px; +} + +.signal-timeline-tooltip-header { + font-weight: 600; + margin-bottom: 4px; + color: var(--accent-cyan, #4a9eff); +} + +.signal-timeline-tooltip-row { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--text-secondary, #888); +} + +.signal-timeline-tooltip-row span:last-child { + color: var(--text-primary, #fff); +} + +/* ============================================ + STATS ROW + ============================================ */ +.signal-timeline-stats { + width: 50px; + min-width: 50px; + padding: 4px 6px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + font-size: 9px; + color: var(--text-dim, #666); + border-left: 1px solid var(--border-color, #333); +} + +.signal-timeline-stat-count { + color: var(--text-primary, #fff); + font-weight: 500; +} + +.signal-timeline-stat-label { + font-size: 8px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* ============================================ + EMPTY STATE + ============================================ */ +.signal-timeline-empty { + text-align: center; + padding: 30px 20px; + color: var(--text-dim, #666); + font-size: 11px; +} + +.signal-timeline-empty-icon { + font-size: 24px; + margin-bottom: 8px; + opacity: 0.5; +} + +/* ============================================ + LEGEND - compact inline version + ============================================ */ +.signal-timeline-legend { + display: none; /* Hide by default - status colors are self-explanatory */ +} + +.signal-timeline-legend-item { + display: flex; + align-items: center; + gap: 3px; +} + +.signal-timeline-legend-dot { + width: 6px; + height: 6px; + border-radius: 2px; +} + +.signal-timeline-legend-dot.new { background: var(--signal-new, #3b82f6); } +.signal-timeline-legend-dot.baseline { background: var(--signal-baseline, #6b7280); } +.signal-timeline-legend-dot.burst { background: var(--signal-burst, #f59e0b); } +.signal-timeline-legend-dot.flagged { background: var(--signal-emergency, #ef4444); } + +/* ============================================ + NOW MARKER + ============================================ */ +.signal-timeline-now { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: var(--accent-green, #22c55e); + z-index: 5; +} + +.signal-timeline-now::after { + content: 'NOW'; + position: absolute; + top: -14px; + left: 50%; + transform: translateX(-50%); + font-size: 8px; + color: var(--accent-green, #22c55e); + font-weight: 600; +} + +/* ============================================ + MARKER (first seen indicator) + ============================================ */ +.signal-timeline-marker { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 8px solid var(--signal-new, #3b82f6); + z-index: 4; +} + +.signal-timeline-marker::after { + content: attr(data-label); + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + font-size: 8px; + color: var(--signal-new, #3b82f6); + white-space: nowrap; +} diff --git a/static/css/components/toast.css b/static/css/components/toast.css index 31b9da4..c4308ac 100644 --- a/static/css/components/toast.css +++ b/static/css/components/toast.css @@ -1,626 +1,626 @@ -/** - * Toast Notification System - * Reusable toast notifications for update alerts and other messages - */ - -/* ============================================ - TOAST CONTAINER - ============================================ */ -#toastContainer { - position: fixed; - bottom: 20px; - right: 20px; - z-index: 10001; - display: flex; - flex-direction: column; - gap: 12px; - pointer-events: none; -} - -#toastContainer > * { - pointer-events: auto; -} - -/* ============================================ - UPDATE TOAST - ============================================ */ -.update-toast { - display: flex; - background: var(--bg-card, #121620); - border: 1px solid var(--border-color, #1f2937); - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - max-width: 340px; - overflow: hidden; - opacity: 0; - transform: translateX(100%); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.update-toast.show { - opacity: 1; - transform: translateX(0); -} - -.update-toast-indicator { - width: 4px; - background: var(--accent-green, #22c55e); - flex-shrink: 0; -} - -.update-toast-content { - flex: 1; - padding: 14px 16px; -} - -.update-toast-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} - -.update-toast-icon { - color: var(--accent-green, #22c55e); - display: flex; - align-items: center; -} - -.update-toast-icon svg { - width: 18px; - height: 18px; -} - -.update-toast-title { - font-size: 13px; - font-weight: 600; - color: var(--text-primary, #e8eaed); - flex: 1; -} - -.update-toast-close { - background: none; - border: none; - color: var(--text-dim, #4b5563); - font-size: 20px; - line-height: 1; - cursor: pointer; - padding: 0; - margin: -4px; - transition: color 0.15s; -} - -.update-toast-close:hover { - color: var(--text-secondary, #9ca3af); -} - -.update-toast-body { - font-size: 12px; - color: var(--text-secondary, #9ca3af); - margin-bottom: 12px; -} - -.update-toast-body strong { - color: var(--accent-cyan, #4a9eff); -} - -.update-toast-actions { - display: flex; - gap: 8px; -} - -.update-toast-btn { - font-family: inherit; - font-size: 11px; - font-weight: 500; - padding: 6px 12px; - border-radius: 4px; - border: none; - cursor: pointer; - transition: all 0.15s; -} - -.update-toast-btn-primary { - background: var(--accent-green, #22c55e); - color: #000; -} - -.update-toast-btn-primary:hover { - background: #34d673; -} - -.update-toast-btn-secondary { - background: var(--bg-secondary, #0f1218); - color: var(--text-secondary, #9ca3af); - border: 1px solid var(--border-color, #1f2937); -} - -.update-toast-btn-secondary:hover { - background: var(--bg-tertiary, #151a23); - border-color: var(--border-light, #374151); -} - -/* ============================================ - UPDATE MODAL - ============================================ */ -.update-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); - z-index: 10002; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - visibility: hidden; - transition: all 0.2s ease; -} - -.update-modal-overlay.show { - opacity: 1; - visibility: visible; -} - -.update-modal { - background: var(--bg-card, #121620); - border: 1px solid var(--border-color, #1f2937); - border-radius: 12px; - width: 90%; - max-width: 520px; - max-height: 85vh; - display: flex; - flex-direction: column; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); - transform: scale(0.95); - transition: transform 0.2s ease; -} - -.update-modal-overlay.show .update-modal { - transform: scale(1); -} - -.update-modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--border-color, #1f2937); -} - -.update-modal-title { - display: flex; - align-items: center; - gap: 10px; - font-size: 16px; - font-weight: 600; - color: var(--text-primary, #e8eaed); -} - -.update-modal-icon { - color: var(--accent-green, #22c55e); - display: flex; -} - -.update-modal-icon svg { - width: 22px; - height: 22px; -} - -.update-modal-close { - background: none; - border: none; - color: var(--text-dim, #4b5563); - font-size: 24px; - line-height: 1; - cursor: pointer; - padding: 4px; - transition: color 0.15s; -} - -.update-modal-close:hover { - color: var(--accent-red, #ef4444); -} - -.update-modal-body { - padding: 20px; - overflow-y: auto; - flex: 1; -} - -/* Version Info */ -.update-version-info { - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - padding: 16px; - background: var(--bg-secondary, #0f1218); - border-radius: 8px; - margin-bottom: 20px; -} - -.update-version-current, -.update-version-latest { - text-align: center; -} - -.update-version-label { - display: block; - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-dim, #4b5563); - margin-bottom: 4px; -} - -.update-version-value { - font-family: 'JetBrains Mono', monospace; - font-size: 18px; - font-weight: 600; - color: var(--text-secondary, #9ca3af); -} - -.update-version-new { - color: var(--accent-green, #22c55e); -} - -.update-version-arrow { - color: var(--text-dim, #4b5563); -} - -.update-version-arrow svg { - width: 20px; - height: 20px; -} - -/* Sections */ -.update-section { - margin-bottom: 20px; -} - -.update-section-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-dim, #4b5563); - margin-bottom: 10px; -} - -.update-release-notes { - font-size: 13px; - color: var(--text-secondary, #9ca3af); - background: var(--bg-secondary, #0f1218); - border: 1px solid var(--border-color, #1f2937); - border-radius: 6px; - padding: 14px; - max-height: 200px; - overflow-y: auto; - line-height: 1.6; -} - -.update-release-notes h2, -.update-release-notes h3, -.update-release-notes h4 { - color: var(--text-primary, #e8eaed); - margin: 16px 0 8px 0; - font-size: 14px; -} - -.update-release-notes h2:first-child, -.update-release-notes h3:first-child, -.update-release-notes h4:first-child { - margin-top: 0; -} - -.update-release-notes ul { - margin: 8px 0; - padding-left: 20px; -} - -.update-release-notes li { - margin: 4px 0; -} - -.update-release-notes code { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - background: var(--bg-tertiary, #151a23); - padding: 2px 6px; - border-radius: 3px; - color: var(--accent-cyan, #4a9eff); -} - -.update-release-notes p { - margin: 8px 0; -} - -/* Warning */ -.update-warning { - display: flex; - gap: 12px; - padding: 14px; - background: rgba(245, 158, 11, 0.1); - border: 1px solid rgba(245, 158, 11, 0.3); - border-radius: 6px; - margin-bottom: 16px; -} - -.update-warning-icon { - color: var(--accent-orange, #f59e0b); - flex-shrink: 0; -} - -.update-warning-icon svg { - width: 20px; - height: 20px; -} - -.update-warning-text { - font-size: 12px; - color: var(--text-secondary, #9ca3af); -} - -.update-warning-text strong { - display: block; - color: var(--accent-orange, #f59e0b); - margin-bottom: 4px; -} - -.update-warning-text p { - margin: 0; -} - -/* Options */ -.update-options { - margin-bottom: 16px; -} - -.update-option { - display: flex; - align-items: center; - gap: 10px; - font-size: 12px; - color: var(--text-secondary, #9ca3af); - cursor: pointer; -} - -.update-option input[type="checkbox"] { - width: 16px; - height: 16px; - accent-color: var(--accent-cyan, #4a9eff); -} - -/* Progress */ -.update-progress { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - padding: 20px; - font-size: 13px; - color: var(--text-secondary, #9ca3af); -} - -.update-progress-spinner { - width: 20px; - height: 20px; - border: 2px solid var(--border-color, #1f2937); - border-top-color: var(--accent-cyan, #4a9eff); - border-radius: 50%; - animation: updateSpin 0.8s linear infinite; -} - -@keyframes updateSpin { - to { transform: rotate(360deg); } -} - -/* Results */ -.update-result { - display: flex; - gap: 12px; - padding: 14px; - border-radius: 6px; - margin-top: 16px; -} - -.update-result-icon { - flex-shrink: 0; -} - -.update-result-icon svg { - width: 20px; - height: 20px; -} - -.update-result-text { - font-size: 12px; - line-height: 1.5; -} - -.update-result-text code { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - background: rgba(0, 0, 0, 0.2); - padding: 2px 6px; - border-radius: 3px; - display: inline-block; - word-break: break-all; -} - -.update-result-success { - background: rgba(34, 197, 94, 0.1); - border: 1px solid rgba(34, 197, 94, 0.3); -} - -.update-result-success .update-result-icon { - color: var(--accent-green, #22c55e); -} - -.update-result-success .update-result-text { - color: var(--accent-green, #22c55e); -} - -.update-result-error { - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); -} - -.update-result-error .update-result-icon { - color: var(--accent-red, #ef4444); -} - -.update-result-error .update-result-text { - color: var(--text-secondary, #9ca3af); -} - -.update-result-error .update-result-text strong { - color: var(--accent-red, #ef4444); -} - -.update-result-warning { - background: rgba(245, 158, 11, 0.1); - border: 1px solid rgba(245, 158, 11, 0.3); -} - -.update-result-warning .update-result-icon { - color: var(--accent-orange, #f59e0b); -} - -.update-result-warning .update-result-text { - color: var(--text-secondary, #9ca3af); -} - -.update-result-warning .update-result-text strong { - color: var(--accent-orange, #f59e0b); -} - -.update-result-info { - background: rgba(74, 158, 255, 0.1); - border: 1px solid rgba(74, 158, 255, 0.3); -} - -.update-result-info .update-result-icon { - color: var(--accent-cyan, #4a9eff); -} - -.update-result-info .update-result-text { - color: var(--text-secondary, #9ca3af); -} - -/* Footer */ -.update-modal-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 20px; - border-top: 1px solid var(--border-color, #1f2937); - background: var(--bg-secondary, #0f1218); - border-radius: 0 0 12px 12px; -} - -.update-modal-link { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--text-dim, #4b5563); - text-decoration: none; - transition: color 0.15s; -} - -.update-modal-link:hover { - color: var(--accent-cyan, #4a9eff); -} - -.update-modal-actions { - display: flex; - gap: 10px; -} - -.update-modal-btn { - font-family: inherit; - font-size: 12px; - font-weight: 500; - padding: 8px 16px; - border-radius: 6px; - border: none; - cursor: pointer; - transition: all 0.15s; -} - -.update-modal-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.update-modal-btn-primary { - background: var(--accent-green, #22c55e); - color: #000; -} - -.update-modal-btn-primary:hover:not(:disabled) { - background: #34d673; -} - -.update-modal-btn-secondary { - background: var(--bg-tertiary, #151a23); - color: var(--text-secondary, #9ca3af); - border: 1px solid var(--border-color, #1f2937); -} - -.update-modal-btn-secondary:hover:not(:disabled) { - background: var(--bg-elevated, #1a202c); - border-color: var(--border-light, #374151); -} - -/* ============================================ - RESPONSIVE - ============================================ */ -@media (max-width: 480px) { - #toastContainer { - bottom: 10px; - right: 10px; - left: 10px; - } - - .update-toast { - max-width: none; - } - - .update-modal { - width: 95%; - max-height: 90vh; - } - - .update-version-info { - flex-direction: column; - gap: 10px; - } - - .update-version-arrow { - transform: rotate(90deg); - } - - .update-modal-footer { - flex-direction: column; - gap: 12px; - } - - .update-modal-link { - order: 2; - } - - .update-modal-actions { - width: 100%; - } - - .update-modal-btn { - flex: 1; - } -} +/** + * Toast Notification System + * Reusable toast notifications for update alerts and other messages + */ + +/* ============================================ + TOAST CONTAINER + ============================================ */ +#toastContainer { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 10001; + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: none; +} + +#toastContainer > * { + pointer-events: auto; +} + +/* ============================================ + UPDATE TOAST + ============================================ */ +.update-toast { + display: flex; + background: var(--bg-card, #121620); + border: 1px solid var(--border-color, #1f2937); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + max-width: 340px; + overflow: hidden; + opacity: 0; + transform: translateX(100%); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.update-toast.show { + opacity: 1; + transform: translateX(0); +} + +.update-toast-indicator { + width: 4px; + background: var(--accent-green, #22c55e); + flex-shrink: 0; +} + +.update-toast-content { + flex: 1; + padding: 14px 16px; +} + +.update-toast-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.update-toast-icon { + color: var(--accent-green, #22c55e); + display: flex; + align-items: center; +} + +.update-toast-icon svg { + width: 18px; + height: 18px; +} + +.update-toast-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #e8eaed); + flex: 1; +} + +.update-toast-close { + background: none; + border: none; + color: var(--text-dim, #4b5563); + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0; + margin: -4px; + transition: color 0.15s; +} + +.update-toast-close:hover { + color: var(--text-secondary, #9ca3af); +} + +.update-toast-body { + font-size: 12px; + color: var(--text-secondary, #9ca3af); + margin-bottom: 12px; +} + +.update-toast-body strong { + color: var(--accent-cyan, #4a9eff); +} + +.update-toast-actions { + display: flex; + gap: 8px; +} + +.update-toast-btn { + font-family: inherit; + font-size: 11px; + font-weight: 500; + padding: 6px 12px; + border-radius: 4px; + border: none; + cursor: pointer; + transition: all 0.15s; +} + +.update-toast-btn-primary { + background: var(--accent-green, #22c55e); + color: #000; +} + +.update-toast-btn-primary:hover { + background: #34d673; +} + +.update-toast-btn-secondary { + background: var(--bg-secondary, #0f1218); + color: var(--text-secondary, #9ca3af); + border: 1px solid var(--border-color, #1f2937); +} + +.update-toast-btn-secondary:hover { + background: var(--bg-tertiary, #151a23); + border-color: var(--border-light, #374151); +} + +/* ============================================ + UPDATE MODAL + ============================================ */ +.update-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 10002; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: all 0.2s ease; +} + +.update-modal-overlay.show { + opacity: 1; + visibility: visible; +} + +.update-modal { + background: var(--bg-card, #121620); + border: 1px solid var(--border-color, #1f2937); + border-radius: 12px; + width: 90%; + max-width: 520px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + transform: scale(0.95); + transition: transform 0.2s ease; +} + +.update-modal-overlay.show .update-modal { + transform: scale(1); +} + +.update-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #1f2937); +} + +.update-modal-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #e8eaed); +} + +.update-modal-icon { + color: var(--accent-green, #22c55e); + display: flex; +} + +.update-modal-icon svg { + width: 22px; + height: 22px; +} + +.update-modal-close { + background: none; + border: none; + color: var(--text-dim, #4b5563); + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 4px; + transition: color 0.15s; +} + +.update-modal-close:hover { + color: var(--accent-red, #ef4444); +} + +.update-modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +/* Version Info */ +.update-version-info { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 16px; + background: var(--bg-secondary, #0f1218); + border-radius: 8px; + margin-bottom: 20px; +} + +.update-version-current, +.update-version-latest { + text-align: center; +} + +.update-version-label { + display: block; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim, #4b5563); + margin-bottom: 4px; +} + +.update-version-value { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + color: var(--text-secondary, #9ca3af); +} + +.update-version-new { + color: var(--accent-green, #22c55e); +} + +.update-version-arrow { + color: var(--text-dim, #4b5563); +} + +.update-version-arrow svg { + width: 20px; + height: 20px; +} + +/* Sections */ +.update-section { + margin-bottom: 20px; +} + +.update-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim, #4b5563); + margin-bottom: 10px; +} + +.update-release-notes { + font-size: 13px; + color: var(--text-secondary, #9ca3af); + background: var(--bg-secondary, #0f1218); + border: 1px solid var(--border-color, #1f2937); + border-radius: 6px; + padding: 14px; + max-height: 200px; + overflow-y: auto; + line-height: 1.6; +} + +.update-release-notes h2, +.update-release-notes h3, +.update-release-notes h4 { + color: var(--text-primary, #e8eaed); + margin: 16px 0 8px 0; + font-size: 14px; +} + +.update-release-notes h2:first-child, +.update-release-notes h3:first-child, +.update-release-notes h4:first-child { + margin-top: 0; +} + +.update-release-notes ul { + margin: 8px 0; + padding-left: 20px; +} + +.update-release-notes li { + margin: 4px 0; +} + +.update-release-notes code { + font-family: var(--font-mono); + font-size: 11px; + background: var(--bg-tertiary, #151a23); + padding: 2px 6px; + border-radius: 3px; + color: var(--accent-cyan, #4a9eff); +} + +.update-release-notes p { + margin: 8px 0; +} + +/* Warning */ +.update-warning { + display: flex; + gap: 12px; + padding: 14px; + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: 6px; + margin-bottom: 16px; +} + +.update-warning-icon { + color: var(--accent-orange, #f59e0b); + flex-shrink: 0; +} + +.update-warning-icon svg { + width: 20px; + height: 20px; +} + +.update-warning-text { + font-size: 12px; + color: var(--text-secondary, #9ca3af); +} + +.update-warning-text strong { + display: block; + color: var(--accent-orange, #f59e0b); + margin-bottom: 4px; +} + +.update-warning-text p { + margin: 0; +} + +/* Options */ +.update-options { + margin-bottom: 16px; +} + +.update-option { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + color: var(--text-secondary, #9ca3af); + cursor: pointer; +} + +.update-option input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent-cyan, #4a9eff); +} + +/* Progress */ +.update-progress { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 20px; + font-size: 13px; + color: var(--text-secondary, #9ca3af); +} + +.update-progress-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-color, #1f2937); + border-top-color: var(--accent-cyan, #4a9eff); + border-radius: 50%; + animation: updateSpin 0.8s linear infinite; +} + +@keyframes updateSpin { + to { transform: rotate(360deg); } +} + +/* Results */ +.update-result { + display: flex; + gap: 12px; + padding: 14px; + border-radius: 6px; + margin-top: 16px; +} + +.update-result-icon { + flex-shrink: 0; +} + +.update-result-icon svg { + width: 20px; + height: 20px; +} + +.update-result-text { + font-size: 12px; + line-height: 1.5; +} + +.update-result-text code { + font-family: var(--font-mono); + font-size: 11px; + background: rgba(0, 0, 0, 0.2); + padding: 2px 6px; + border-radius: 3px; + display: inline-block; + word-break: break-all; +} + +.update-result-success { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.update-result-success .update-result-icon { + color: var(--accent-green, #22c55e); +} + +.update-result-success .update-result-text { + color: var(--accent-green, #22c55e); +} + +.update-result-error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.update-result-error .update-result-icon { + color: var(--accent-red, #ef4444); +} + +.update-result-error .update-result-text { + color: var(--text-secondary, #9ca3af); +} + +.update-result-error .update-result-text strong { + color: var(--accent-red, #ef4444); +} + +.update-result-warning { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.update-result-warning .update-result-icon { + color: var(--accent-orange, #f59e0b); +} + +.update-result-warning .update-result-text { + color: var(--text-secondary, #9ca3af); +} + +.update-result-warning .update-result-text strong { + color: var(--accent-orange, #f59e0b); +} + +.update-result-info { + background: rgba(74, 158, 255, 0.1); + border: 1px solid rgba(74, 158, 255, 0.3); +} + +.update-result-info .update-result-icon { + color: var(--accent-cyan, #4a9eff); +} + +.update-result-info .update-result-text { + color: var(--text-secondary, #9ca3af); +} + +/* Footer */ +.update-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-top: 1px solid var(--border-color, #1f2937); + background: var(--bg-secondary, #0f1218); + border-radius: 0 0 12px 12px; +} + +.update-modal-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-dim, #4b5563); + text-decoration: none; + transition: color 0.15s; +} + +.update-modal-link:hover { + color: var(--accent-cyan, #4a9eff); +} + +.update-modal-actions { + display: flex; + gap: 10px; +} + +.update-modal-btn { + font-family: inherit; + font-size: 12px; + font-weight: 500; + padding: 8px 16px; + border-radius: 6px; + border: none; + cursor: pointer; + transition: all 0.15s; +} + +.update-modal-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.update-modal-btn-primary { + background: var(--accent-green, #22c55e); + color: #000; +} + +.update-modal-btn-primary:hover:not(:disabled) { + background: #34d673; +} + +.update-modal-btn-secondary { + background: var(--bg-tertiary, #151a23); + color: var(--text-secondary, #9ca3af); + border: 1px solid var(--border-color, #1f2937); +} + +.update-modal-btn-secondary:hover:not(:disabled) { + background: var(--bg-elevated, #1a202c); + border-color: var(--border-light, #374151); +} + +/* ============================================ + RESPONSIVE + ============================================ */ +@media (max-width: 480px) { + #toastContainer { + bottom: 10px; + right: 10px; + left: 10px; + } + + .update-toast { + max-width: none; + } + + .update-modal { + width: 95%; + max-height: 90vh; + } + + .update-version-info { + flex-direction: column; + gap: 10px; + } + + .update-version-arrow { + transform: rotate(90deg); + } + + .update-modal-footer { + flex-direction: column; + gap: 12px; + } + + .update-modal-link { + order: 2; + } + + .update-modal-actions { + width: 100%; + } + + .update-modal-btn { + flex: 1; + } +} diff --git a/static/css/core/variables.css b/static/css/core/variables.css index 32b79a0..318717e 100644 --- a/static/css/core/variables.css +++ b/static/css/core/variables.css @@ -73,8 +73,8 @@ /* ============================================ TYPOGRAPHY ============================================ */ - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'Terminus', 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; /* Font sizes */ --text-xs: 10px; diff --git a/static/css/fonts-local.css b/static/css/fonts-local.css index 7f0167d..49e61c6 100644 --- a/static/css/fonts-local.css +++ b/static/css/fonts-local.css @@ -33,7 +33,7 @@ src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2'); } -/* JetBrains Mono - Monospace/code font */ +/* JetBrains Mono - Monospace/code font */ @font-face { font-family: 'JetBrains Mono'; font-style: normal; @@ -58,10 +58,19 @@ src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2'); } -@font-face { - font-family: 'JetBrains Mono'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2'); -} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2'); +} + +/* Terminus - Monospace UI font */ +@font-face { + font-family: 'Terminus'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/static/vendor/fonts/Terminus.ttf') format('truetype'); +} diff --git a/static/css/global-nav.css b/static/css/global-nav.css index 8a4d82f..9b10fbb 100644 --- a/static/css/global-nav.css +++ b/static/css/global-nav.css @@ -50,7 +50,7 @@ letter-spacing: 1px; margin-right: 8px; font-weight: 500; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } .mode-nav-divider { @@ -80,7 +80,7 @@ .mode-nav-btn .nav-label { text-transform: uppercase; letter-spacing: 0.08em; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; } @@ -127,7 +127,7 @@ .nav-action-btn .nav-label { text-transform: uppercase; letter-spacing: 0.08em; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; } @@ -162,7 +162,7 @@ .mode-nav-dropdown-btn .nav-label { text-transform: uppercase; letter-spacing: 0.08em; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; } @@ -268,7 +268,7 @@ display: flex; align-items: center; gap: 6px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; flex-shrink: 0; white-space: nowrap; diff --git a/static/css/index.css b/static/css/index.css index 301904b..2cbf607 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -1,5 +1,13 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); +@font-face { + font-family: 'Terminus'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/static/vendor/fonts/Terminus.ttf') format('truetype'); +} + * { box-sizing: border-box; margin: 0; @@ -259,7 +267,7 @@ body { } .welcome-title { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 2.5rem; font-weight: 700; color: var(--text-primary); @@ -269,7 +277,7 @@ body { } .welcome-tagline { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 0.9rem; color: var(--accent-cyan); letter-spacing: 0.15em; @@ -278,7 +286,7 @@ body { .welcome-version { display: inline-block; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 0.65rem; color: var(--bg-primary); background: var(--accent-cyan); @@ -297,7 +305,7 @@ body { } .welcome-content h2 { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase; @@ -333,7 +341,7 @@ body { } .changelog-version { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 0.8rem; color: var(--accent-cyan); font-weight: 600; @@ -364,7 +372,7 @@ body { position: absolute; left: -15px; color: var(--accent-green); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } /* Mode Selection Grid */ @@ -435,7 +443,7 @@ body { } .mode-card .mode-name { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 0.75rem; font-weight: 600; color: var(--text-primary); @@ -463,7 +471,7 @@ body { display: flex; align-items: center; gap: 8px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600; color: var(--text-secondary); @@ -922,7 +930,7 @@ header h1 { display: flex; align-items: center; gap: 6px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; flex-shrink: 0; white-space: nowrap; @@ -1030,7 +1038,7 @@ header h1 { .version-badge { font-size: 0.6rem; font-weight: 500; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); letter-spacing: 0.05em; color: var(--text-secondary); background: var(--bg-tertiary); @@ -1089,7 +1097,7 @@ header h1 .tagline { background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); transition: all 0.2s ease; } @@ -1578,7 +1586,7 @@ header h1 .tagline { border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; transition: all 0.15s ease; } @@ -1637,7 +1645,7 @@ header h1 .tagline { border: 1px solid var(--border-color); color: var(--text-secondary); cursor: pointer; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; @@ -1757,7 +1765,7 @@ header h1 .tagline { gap: 8px; font-size: 10px; color: var(--text-secondary); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } .stats>div { @@ -1783,7 +1791,7 @@ header h1 .tagline { flex: 1; padding: 10px; overflow-y: auto; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; background: var(--bg-primary); min-height: 0; /* Allow shrinking in flex context */ @@ -1855,7 +1863,7 @@ header h1 .tagline { .message .address { color: var(--accent-green); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; margin-bottom: 8px; } @@ -1868,7 +1876,7 @@ header h1 .tagline { } .message .content.numeric { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 15px; letter-spacing: 2px; color: var(--accent-cyan); @@ -2357,7 +2365,7 @@ header h1 .tagline { /* Dark theme for Leaflet */ .leaflet-container { background: #0a0a0a; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } /* Using actual dark tiles now - no filter needed */ @@ -2394,7 +2402,7 @@ header h1 .tagline { display: flex; justify-content: space-between; z-index: 1000; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; color: var(--accent-cyan); text-shadow: 0 0 5px var(--accent-cyan); @@ -2411,7 +2419,7 @@ header h1 .tagline { display: flex; justify-content: space-between; z-index: 1000; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; color: var(--accent-cyan); text-shadow: 0 0 5px var(--accent-cyan); @@ -2432,7 +2440,7 @@ header h1 .tagline { } .aircraft-popup { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; } @@ -2476,7 +2484,7 @@ header h1 .tagline { background: rgba(0, 0, 0, 0.8) !important; border: 1px solid var(--accent-cyan) !important; color: var(--accent-cyan) !important; - font-family: 'JetBrains Mono', monospace !important; + font-family: var(--font-mono) !important; font-size: 10px !important; padding: 2px 6px !important; border-radius: 2px !important; @@ -2720,7 +2728,7 @@ header h1 .tagline { color: var(--accent-cyan); font-size: 22px; font-weight: 700; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); text-shadow: 0 0 15px var(--accent-cyan-dim); line-height: 1.2; } @@ -3114,7 +3122,7 @@ header h1 .tagline { } .sensor-card .data-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 14px; color: var(--accent-cyan); } @@ -3164,7 +3172,7 @@ header h1 .tagline { display: flex; gap: 15px; font-size: 10px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } .recon-stats span { @@ -3214,14 +3222,14 @@ header h1 .tagline { .device-id { color: var(--text-dim); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; } .device-meta { text-align: right; color: var(--text-secondary); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } .device-meta.encrypted { @@ -3297,7 +3305,7 @@ header h1 .tagline { } .hex-dump { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); background: var(--bg-primary); @@ -3969,7 +3977,7 @@ header h1 .tagline { } .bt-detail-address { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; color: #00d4ff; } @@ -3983,7 +3991,7 @@ header h1 .tagline { } .bt-detail-rssi-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 20px; font-weight: 700; } @@ -4078,7 +4086,7 @@ header h1 .tagline { } .bt-detail-services-list { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); white-space: nowrap; @@ -4325,7 +4333,7 @@ header h1 .tagline { } .bt-rssi-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; font-weight: 600; min-width: 28px; @@ -4684,7 +4692,7 @@ header h1 .tagline { flex-direction: column; gap: 4px; font-size: 10px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } .security-legend-item { @@ -4731,7 +4739,7 @@ header h1 .tagline { } .signal-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 28px; color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan-dim); @@ -5247,7 +5255,7 @@ body::before { .meter-value { font-size: 10px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); color: var(--text-secondary); width: 50px; text-align: right; @@ -5404,7 +5412,7 @@ body::before { } .freq-digits { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 56px; font-weight: 700; color: var(--accent-cyan); @@ -5425,7 +5433,7 @@ body::before { } .freq-unit { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 20px; color: var(--text-secondary); margin-left: 8px; @@ -5569,7 +5577,7 @@ body::before { } .knob-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 16px; font-weight: 600; color: var(--accent-cyan); @@ -5694,7 +5702,7 @@ body::before { background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-secondary); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; font-weight: 600; border-radius: 4px; @@ -5756,13 +5764,13 @@ body::before { } .signal-arc-label { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 8px; fill: var(--text-muted); } .signal-arc-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 14px; font-weight: 600; fill: var(--accent-cyan); @@ -5794,7 +5802,7 @@ body::before { border: 1px solid var(--border-color); border-radius: 4px; color: var(--accent-cyan); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 14px; font-weight: 600; text-align: center; @@ -5930,7 +5938,7 @@ body::before { max-height: 200px; overflow-y: auto; padding: 10px 15px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; } @@ -6019,7 +6027,7 @@ body::before { } .module-header { - font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-family: 'Orbitron', 'Terminus', monospace; font-size: 10px; font-weight: 600; color: var(--accent-cyan); @@ -6044,7 +6052,7 @@ body::before { border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; padding: 6px 8px; text-align: center; @@ -6102,7 +6110,7 @@ body::before { } .stat-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 22px; font-weight: bold; } @@ -6150,7 +6158,7 @@ body::before { .tune-btn { padding: 4px 8px; font-size: 10px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); background: var(--bg-elevated); border: 1px solid var(--border-color); color: var(--text-secondary); @@ -6180,13 +6188,13 @@ body::before { background: rgba(0, 0, 0, 0.2); border-radius: 4px; padding: 8px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } /* Listening Mode Selector Buttons */ .radio-mode-btn { padding: 12px 24px; - font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-family: 'Orbitron', 'Terminus', monospace; font-size: 13px; font-weight: 600; text-transform: uppercase; @@ -6227,7 +6235,7 @@ body::before { /* Frequency Preset Buttons */ .preset-freq-btn { padding: 8px 14px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; background: var(--bg-elevated); border: 1px solid var(--border-color); @@ -6291,4 +6299,4 @@ body::before { [data-animations="off"] .logo-dot, [data-animations="off"] .welcome-logo { animation: none !important; -} \ No newline at end of file +} diff --git a/static/css/login.css b/static/css/login.css index ca1da91..65c0663 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -37,7 +37,7 @@ /* Typography */ .landing-title { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 2.2rem; font-weight: 700; letter-spacing: 0.4em; @@ -48,7 +48,7 @@ } .landing-tagline { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); color: var(--accent-cyan); font-size: 0.9rem; letter-spacing: 0.15em; @@ -71,7 +71,7 @@ /* Hacker Style Error */ .flash-error { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; color: var(--accent-red); background: rgba(239, 68, 68, 0.1); @@ -94,7 +94,7 @@ color: var(--accent-cyan); padding: 12px; margin-bottom: 15px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; outline: none; box-sizing: border-box; /* Crucial for visibility */ @@ -106,7 +106,7 @@ border: 2px solid var(--accent-cyan); color: var(--accent-cyan); padding: 15px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-weight: 600; letter-spacing: 3px; cursor: pointer; @@ -116,7 +116,7 @@ .landing-version { margin-top: 25px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; color: rgba(255, 255, 255, 0.3); letter-spacing: 2px; diff --git a/static/css/modes/aprs.css b/static/css/modes/aprs.css index cb19ccb..f28ccf7 100644 --- a/static/css/modes/aprs.css +++ b/static/css/modes/aprs.css @@ -1,328 +1,328 @@ -/* APRS Function Bar (Stats Strip) Styles */ -.aprs-strip { - background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 6px 12px; - margin-bottom: 10px; - overflow-x: auto; -} -.aprs-strip-inner { - display: flex; - align-items: center; - gap: 8px; - min-width: max-content; -} -.aprs-strip .strip-stat { - display: flex; - flex-direction: column; - align-items: center; - padding: 4px 10px; - background: rgba(74, 158, 255, 0.05); - border: 1px solid rgba(74, 158, 255, 0.15); - border-radius: 4px; - min-width: 55px; -} -.aprs-strip .strip-stat:hover { - background: rgba(74, 158, 255, 0.1); - border-color: rgba(74, 158, 255, 0.3); -} -.aprs-strip .strip-value { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 600; - color: var(--accent-cyan); - line-height: 1.2; -} -.aprs-strip .strip-label { - font-size: 8px; - font-weight: 600; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-top: 1px; -} -.aprs-strip .strip-divider { - width: 1px; - height: 28px; - background: var(--border-color); - margin: 0 4px; -} -/* Signal stat coloring */ -.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); } -.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); } -.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); } - -/* Controls */ -.aprs-strip .strip-control { - display: flex; - align-items: center; - gap: 4px; -} -.aprs-strip .strip-select { - background: rgba(0,0,0,0.3); - border: 1px solid var(--border-color); - color: var(--text-primary); - padding: 4px 8px; - border-radius: 4px; - font-size: 10px; - cursor: pointer; -} -.aprs-strip .strip-select:hover { - border-color: var(--accent-cyan); -} -.aprs-strip .strip-input-label { - font-size: 9px; - color: var(--text-muted); - font-weight: 600; -} -.aprs-strip .strip-input { - background: rgba(0,0,0,0.3); - border: 1px solid var(--border-color); - color: var(--text-primary); - padding: 4px 6px; - border-radius: 4px; - font-size: 10px; - width: 50px; - text-align: center; -} -.aprs-strip .strip-input:hover, -.aprs-strip .strip-input:focus { - border-color: var(--accent-cyan); - outline: none; -} - -/* Tool Status Indicators */ -.aprs-strip .strip-tools { - display: flex; - gap: 4px; -} -.aprs-strip .strip-tool { - font-size: 9px; - font-weight: 600; - padding: 3px 6px; - border-radius: 3px; - background: rgba(255, 59, 48, 0.2); - color: var(--accent-red); - border: 1px solid rgba(255, 59, 48, 0.3); -} -.aprs-strip .strip-tool.ok { - background: rgba(0, 255, 136, 0.1); - color: var(--accent-green); - border-color: rgba(0, 255, 136, 0.3); -} - -/* Buttons */ -.aprs-strip .strip-btn { - background: rgba(74, 158, 255, 0.1); - border: 1px solid rgba(74, 158, 255, 0.2); - color: var(--text-primary); - padding: 6px 12px; - border-radius: 4px; - font-size: 10px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - white-space: nowrap; -} -.aprs-strip .strip-btn:hover:not(:disabled) { - background: rgba(74, 158, 255, 0.2); - border-color: rgba(74, 158, 255, 0.4); -} -.aprs-strip .strip-btn.primary { - background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%); - border: none; - color: #000; -} -.aprs-strip .strip-btn.primary:hover:not(:disabled) { - filter: brightness(1.1); -} -.aprs-strip .strip-btn.stop { - background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); - border: none; - color: #fff; -} -.aprs-strip .strip-btn.stop:hover:not(:disabled) { - filter: brightness(1.1); -} -.aprs-strip .strip-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Status indicator */ -.aprs-strip .strip-status { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background: rgba(0,0,0,0.2); - border-radius: 4px; - font-size: 10px; - font-weight: 600; - color: var(--text-secondary); -} -.aprs-strip .status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-muted); -} -.aprs-strip .status-dot.listening { - background: var(--accent-cyan); - animation: aprs-strip-pulse 1.5s ease-in-out infinite; -} -.aprs-strip .status-dot.tracking { - background: var(--accent-green); - animation: aprs-strip-pulse 1.5s ease-in-out infinite; -} -.aprs-strip .status-dot.error { - background: var(--accent-red); -} -@keyframes aprs-strip-pulse { - 0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; } - 50% { opacity: 0.6; box-shadow: none; } -} - -/* Time display */ -.aprs-strip .strip-time { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-muted); - padding: 4px 8px; - background: rgba(0,0,0,0.2); - border-radius: 4px; - white-space: nowrap; -} - -/* APRS Status Bar Styles (Sidebar - legacy) */ -.aprs-status-bar { - margin-top: 12px; - padding: 10px; - background: rgba(0,0,0,0.3); - border: 1px solid var(--border-color); - border-radius: 4px; -} -.aprs-status-indicator { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} -.aprs-status-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--text-muted); -} -.aprs-status-dot.standby { background: var(--text-muted); } -.aprs-status-dot.listening { - background: var(--accent-cyan); - animation: aprs-pulse 1.5s ease-in-out infinite; -} -.aprs-status-dot.tracking { background: var(--accent-green); } -.aprs-status-dot.error { background: var(--accent-red); } -@keyframes aprs-pulse { - 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); } - 50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); } -} -.aprs-status-text { - font-size: 10px; - font-weight: bold; - letter-spacing: 1px; -} -.aprs-status-stats { - display: flex; - flex-wrap: wrap; - gap: 8px; - font-size: 9px; -} -.aprs-stat { - color: var(--text-secondary); -} -.aprs-stat-label { - color: var(--text-muted); -} - -/* Signal Meter Styles */ -.aprs-signal-meter { - margin-top: 12px; - padding: 10px; - background: rgba(0,0,0,0.3); - border: 1px solid var(--border-color); - border-radius: 4px; -} -.aprs-meter-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} -.aprs-meter-label { - font-size: 10px; - font-weight: bold; - letter-spacing: 1px; - color: var(--text-secondary); -} -.aprs-meter-value { - font-size: 12px; - font-weight: bold; - font-family: monospace; - color: var(--accent-cyan); - min-width: 24px; -} -.aprs-meter-burst { - font-size: 9px; - font-weight: bold; - color: var(--accent-yellow); - background: rgba(255, 193, 7, 0.2); - padding: 2px 6px; - border-radius: 3px; - animation: burst-flash 0.3s ease-out; -} -@keyframes burst-flash { - 0% { opacity: 1; transform: scale(1.1); } - 100% { opacity: 1; transform: scale(1); } -} -.aprs-meter-bar-container { - position: relative; - height: 16px; - background: rgba(0,0,0,0.4); - border-radius: 3px; - overflow: hidden; - margin-bottom: 4px; -} -.aprs-meter-bar { - height: 100%; - width: 0%; - background: linear-gradient(90deg, - var(--accent-green) 0%, - var(--accent-cyan) 50%, - var(--accent-yellow) 75%, - var(--accent-red) 100% - ); - border-radius: 3px; - transition: width 0.1s ease-out; -} -.aprs-meter-bar.no-signal { - opacity: 0.3; -} -.aprs-meter-ticks { - display: flex; - justify-content: space-between; - font-size: 8px; - color: var(--text-muted); - padding: 0 2px; -} -.aprs-meter-status { - font-size: 9px; - color: var(--text-muted); - text-align: center; - margin-top: 6px; -} -.aprs-meter-status.active { - color: var(--accent-green); -} -.aprs-meter-status.no-signal { - color: var(--accent-yellow); -} +/* APRS Function Bar (Stats Strip) Styles */ +.aprs-strip { + background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 12px; + margin-bottom: 10px; + overflow-x: auto; +} +.aprs-strip-inner { + display: flex; + align-items: center; + gap: 8px; + min-width: max-content; +} +.aprs-strip .strip-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 10px; + background: rgba(74, 158, 255, 0.05); + border: 1px solid rgba(74, 158, 255, 0.15); + border-radius: 4px; + min-width: 55px; +} +.aprs-strip .strip-stat:hover { + background: rgba(74, 158, 255, 0.1); + border-color: rgba(74, 158, 255, 0.3); +} +.aprs-strip .strip-value { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--accent-cyan); + line-height: 1.2; +} +.aprs-strip .strip-label { + font-size: 8px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 1px; +} +.aprs-strip .strip-divider { + width: 1px; + height: 28px; + background: var(--border-color); + margin: 0 4px; +} +/* Signal stat coloring */ +.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); } +.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); } +.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); } + +/* Controls */ +.aprs-strip .strip-control { + display: flex; + align-items: center; + gap: 4px; +} +.aprs-strip .strip-select { + background: rgba(0,0,0,0.3); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 4px 8px; + border-radius: 4px; + font-size: 10px; + cursor: pointer; +} +.aprs-strip .strip-select:hover { + border-color: var(--accent-cyan); +} +.aprs-strip .strip-input-label { + font-size: 9px; + color: var(--text-muted); + font-weight: 600; +} +.aprs-strip .strip-input { + background: rgba(0,0,0,0.3); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 4px 6px; + border-radius: 4px; + font-size: 10px; + width: 50px; + text-align: center; +} +.aprs-strip .strip-input:hover, +.aprs-strip .strip-input:focus { + border-color: var(--accent-cyan); + outline: none; +} + +/* Tool Status Indicators */ +.aprs-strip .strip-tools { + display: flex; + gap: 4px; +} +.aprs-strip .strip-tool { + font-size: 9px; + font-weight: 600; + padding: 3px 6px; + border-radius: 3px; + background: rgba(255, 59, 48, 0.2); + color: var(--accent-red); + border: 1px solid rgba(255, 59, 48, 0.3); +} +.aprs-strip .strip-tool.ok { + background: rgba(0, 255, 136, 0.1); + color: var(--accent-green); + border-color: rgba(0, 255, 136, 0.3); +} + +/* Buttons */ +.aprs-strip .strip-btn { + background: rgba(74, 158, 255, 0.1); + border: 1px solid rgba(74, 158, 255, 0.2); + color: var(--text-primary); + padding: 6px 12px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} +.aprs-strip .strip-btn:hover:not(:disabled) { + background: rgba(74, 158, 255, 0.2); + border-color: rgba(74, 158, 255, 0.4); +} +.aprs-strip .strip-btn.primary { + background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%); + border: none; + color: #000; +} +.aprs-strip .strip-btn.primary:hover:not(:disabled) { + filter: brightness(1.1); +} +.aprs-strip .strip-btn.stop { + background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); + border: none; + color: #fff; +} +.aprs-strip .strip-btn.stop:hover:not(:disabled) { + filter: brightness(1.1); +} +.aprs-strip .strip-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Status indicator */ +.aprs-strip .strip-status { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: rgba(0,0,0,0.2); + border-radius: 4px; + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); +} +.aprs-strip .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); +} +.aprs-strip .status-dot.listening { + background: var(--accent-cyan); + animation: aprs-strip-pulse 1.5s ease-in-out infinite; +} +.aprs-strip .status-dot.tracking { + background: var(--accent-green); + animation: aprs-strip-pulse 1.5s ease-in-out infinite; +} +.aprs-strip .status-dot.error { + background: var(--accent-red); +} +@keyframes aprs-strip-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; } + 50% { opacity: 0.6; box-shadow: none; } +} + +/* Time display */ +.aprs-strip .strip-time { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + padding: 4px 8px; + background: rgba(0,0,0,0.2); + border-radius: 4px; + white-space: nowrap; +} + +/* APRS Status Bar Styles (Sidebar - legacy) */ +.aprs-status-bar { + margin-top: 12px; + padding: 10px; + background: rgba(0,0,0,0.3); + border: 1px solid var(--border-color); + border-radius: 4px; +} +.aprs-status-indicator { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.aprs-status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-muted); +} +.aprs-status-dot.standby { background: var(--text-muted); } +.aprs-status-dot.listening { + background: var(--accent-cyan); + animation: aprs-pulse 1.5s ease-in-out infinite; +} +.aprs-status-dot.tracking { background: var(--accent-green); } +.aprs-status-dot.error { background: var(--accent-red); } +@keyframes aprs-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); } + 50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); } +} +.aprs-status-text { + font-size: 10px; + font-weight: bold; + letter-spacing: 1px; +} +.aprs-status-stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 9px; +} +.aprs-stat { + color: var(--text-secondary); +} +.aprs-stat-label { + color: var(--text-muted); +} + +/* Signal Meter Styles */ +.aprs-signal-meter { + margin-top: 12px; + padding: 10px; + background: rgba(0,0,0,0.3); + border: 1px solid var(--border-color); + border-radius: 4px; +} +.aprs-meter-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.aprs-meter-label { + font-size: 10px; + font-weight: bold; + letter-spacing: 1px; + color: var(--text-secondary); +} +.aprs-meter-value { + font-size: 12px; + font-weight: bold; + font-family: monospace; + color: var(--accent-cyan); + min-width: 24px; +} +.aprs-meter-burst { + font-size: 9px; + font-weight: bold; + color: var(--accent-yellow); + background: rgba(255, 193, 7, 0.2); + padding: 2px 6px; + border-radius: 3px; + animation: burst-flash 0.3s ease-out; +} +@keyframes burst-flash { + 0% { opacity: 1; transform: scale(1.1); } + 100% { opacity: 1; transform: scale(1); } +} +.aprs-meter-bar-container { + position: relative; + height: 16px; + background: rgba(0,0,0,0.4); + border-radius: 3px; + overflow: hidden; + margin-bottom: 4px; +} +.aprs-meter-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, + var(--accent-green) 0%, + var(--accent-cyan) 50%, + var(--accent-yellow) 75%, + var(--accent-red) 100% + ); + border-radius: 3px; + transition: width 0.1s ease-out; +} +.aprs-meter-bar.no-signal { + opacity: 0.3; +} +.aprs-meter-ticks { + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--text-muted); + padding: 0 2px; +} +.aprs-meter-status { + font-size: 9px; + color: var(--text-muted); + text-align: center; + margin-top: 6px; +} +.aprs-meter-status.active { + color: var(--accent-green); +} +.aprs-meter-status.no-signal { + color: var(--accent-yellow); +} diff --git a/static/css/modes/meshtastic.css b/static/css/modes/meshtastic.css index 3602dc0..e424de6 100644 --- a/static/css/modes/meshtastic.css +++ b/static/css/modes/meshtastic.css @@ -1,1610 +1,1610 @@ -/** - * Meshtastic Mode Styles - * Mesh network monitoring interface - */ - -/* ============================================ - MODE VISIBILITY - ============================================ */ -#meshtasticMode.active { - display: block !important; -} - -/* ============================================ - MAIN SIDEBAR COLLAPSE (for Meshtastic mode) - ============================================ */ - -/* When sidebar is hidden, adjust layout */ -.main-content.mesh-sidebar-hidden { - display: flex !important; - flex-direction: column !important; -} - -.main-content.mesh-sidebar-hidden > .sidebar { - display: none !important; - width: 0 !important; - height: 0 !important; - overflow: hidden !important; -} - -.main-content.mesh-sidebar-hidden > .output-panel { - flex: 1 !important; - width: 100% !important; - max-width: 100% !important; -} - -/* Hide Sidebar Button in sidebar */ -.mesh-hide-sidebar-btn { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - width: 100%; - padding: 10px 12px; - margin-bottom: 12px; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 6px; - cursor: pointer; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - transition: all 0.15s ease; -} - -.mesh-hide-sidebar-btn:hover { - background: var(--bg-secondary); - border-color: var(--accent-cyan); - color: var(--accent-cyan); -} - -.mesh-hide-sidebar-btn svg { - width: 14px; - height: 14px; -} - -/* When sidebar is hidden, highlight the toggle button in stats strip */ -.main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle { - background: var(--accent-cyan); - border-color: var(--accent-cyan); - color: var(--bg-primary); -} - -.main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle:hover { - background: var(--accent-blue); - border-color: var(--accent-blue); -} - -/* Sidebar toggle button in stats strip */ -.mesh-strip-sidebar-toggle { - display: flex; - align-items: center; - gap: 6px; - padding: 5px 10px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - cursor: pointer; - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-secondary); - transition: all 0.15s ease; - pointer-events: auto; - z-index: 100; - position: relative; -} - -.mesh-strip-sidebar-toggle:hover { - background: var(--bg-secondary); - border-color: var(--border-light); - color: var(--text-primary); -} - -.mesh-strip-sidebar-toggle svg { - width: 14px; - height: 14px; - transition: transform 0.2s ease; -} - -@media (min-width: 1024px) { - .main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle svg { - transform: rotate(180deg); - } -} - -/* ============================================ - COLLAPSIBLE SIDEBAR CONTENT - ============================================ */ -.mesh-sidebar-toggle { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; - cursor: pointer; - margin-bottom: 12px; - transition: all 0.15s ease; -} - -.mesh-sidebar-toggle:hover { - background: var(--bg-card); - border-color: var(--border-light); -} - -.mesh-sidebar-toggle-icon { - font-size: 10px; - color: var(--text-dim); - transition: transform 0.2s ease; -} - -.mesh-sidebar-toggle-text { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.mesh-sidebar-content { - display: block; - transition: all 0.2s ease; -} - -/* Collapsed state */ -#meshtasticMode.mesh-sidebar-collapsed .mesh-sidebar-content { - display: none; -} - -#meshtasticMode.mesh-sidebar-collapsed .mesh-sidebar-toggle-icon { - transform: rotate(0deg); -} - -#meshtasticMode:not(.mesh-sidebar-collapsed) .mesh-sidebar-toggle-icon { - transform: rotate(90deg); -} - -/* ============================================ - MAIN VISUALS CONTAINER - ============================================ */ -.mesh-visuals-container { - display: flex; - flex-direction: column; - gap: 16px; - padding: 16px; - min-height: 0; - flex: 1; - overflow: hidden; -} - -/* ============================================ - MAIN ROW (Map + Messages side by side) - ============================================ */ -.mesh-main-row { - display: flex; - flex-direction: row; - gap: 16px; - flex: 1; - min-height: 0; - overflow: hidden; -} - -/* ============================================ - STATS STRIP (Compact Header Bar) - ============================================ */ -.mesh-stats-strip { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 16px; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - flex-wrap: wrap; -} - -.mesh-strip-group { - display: flex; - align-items: center; - gap: 12px; -} - -.mesh-strip-status { - display: flex; - align-items: center; - gap: 6px; -} - -.mesh-strip-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.mesh-strip-dot.disconnected { - background: var(--text-dim); -} - -.mesh-strip-dot.connecting { - background: var(--accent-yellow); - animation: pulse 1s infinite; -} - -.mesh-strip-dot.connected { - background: var(--accent-green); - box-shadow: 0 0 6px var(--accent-green); -} - -.mesh-strip-status-text { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-secondary); - text-transform: uppercase; -} - -.mesh-strip-select { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - padding: 4px 8px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); - max-width: 120px; -} - -.mesh-strip-input { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - padding: 4px 8px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); - max-width: 140px; -} - -.mesh-strip-input::placeholder { - color: var(--text-secondary); - opacity: 0.7; -} - -.mesh-strip-input:focus { - outline: none; - border-color: var(--accent-cyan); -} - -.mesh-strip-btn { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - padding: 5px 12px; - border: none; - border-radius: 4px; - cursor: pointer; - text-transform: uppercase; - font-weight: 600; - transition: all 0.15s ease; -} - -.mesh-strip-btn.connect { - background: var(--accent-cyan); - color: var(--bg-primary); -} - -.mesh-strip-btn.connect:hover { - background: var(--accent-cyan-bright, #00d4ff); -} - -.mesh-strip-btn.disconnect { - background: var(--accent-red, #ff3366); - color: white; -} - -.mesh-strip-btn.disconnect:hover { - background: #ff1a53; -} - -.mesh-strip-divider { - width: 1px; - height: 24px; - background: var(--border-color); -} - -.mesh-strip-stat { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - min-width: 50px; -} - -.mesh-strip-value { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100px; -} - -.mesh-strip-value.accent-cyan { - color: var(--accent-cyan); -} - -.mesh-strip-value.accent-green { - color: var(--accent-green); -} - -.mesh-strip-id { - font-size: 10px; - color: var(--accent-cyan); -} - -.mesh-strip-label { - font-family: 'JetBrains Mono', monospace; - font-size: 8px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -@media (max-width: 768px) { - .mesh-stats-strip { - padding: 8px 12px; - gap: 8px; - } - - .mesh-strip-group { - gap: 8px; - } - - .mesh-strip-divider { - display: none; - } - - .mesh-strip-stat { - min-width: 40px; - } - - .mesh-strip-value { - font-size: 11px; - max-width: 60px; - } -} - -/* ============================================ - NODE MAP SECTION - ============================================ */ -.mesh-map-section { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - overflow: hidden; - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; - min-height: 400px; -} - -.mesh-map-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - background: rgba(0, 0, 0, 0.2); - border-bottom: 1px solid var(--border-color); -} - -.mesh-map-title { - display: flex; - align-items: center; - gap: 8px; - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--text-primary); -} - -.mesh-map-title svg { - color: var(--accent-cyan); -} - -.mesh-map-stats { - display: flex; - gap: 16px; - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-secondary); -} - -.mesh-map-stats span:last-child { - color: var(--accent-green); -} - -.mesh-map { - flex: 1; - min-height: 0; - background: var(--bg-primary); -} - -/* Leaflet map overrides for dark theme */ -.mesh-map .leaflet-container { - background: var(--bg-primary); -} - -/* Override Leaflet's default div-icon styling for mesh markers */ -.mesh-marker-wrapper.leaflet-div-icon { - background: transparent; - border: none; -} - -.mesh-map .leaflet-popup-content-wrapper { - background: var(--bg-card); - color: var(--text-primary); - border-radius: 6px; - border: 1px solid var(--border-color); -} - -.mesh-map .leaflet-popup-tip { - background: var(--bg-card); -} - -.mesh-map .leaflet-popup-content { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - margin: 10px 12px; -} - -/* Custom node marker - high visibility on dark maps */ -.mesh-node-marker { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - background: #00ffff; /* Bright cyan for maximum visibility */ - border: 3px solid #ffffff; - border-radius: 50%; - box-shadow: - 0 2px 8px rgba(0, 0, 0, 0.6), - 0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */ - inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */ - color: #000; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - font-weight: bold; - text-shadow: 0 0 2px rgba(255, 255, 255, 0.8); -} - -.mesh-node-marker.local { - background: #00ff88; /* Bright green for local node */ - box-shadow: - 0 2px 8px rgba(0, 0, 0, 0.6), - 0 0 20px 8px rgba(0, 255, 136, 0.7), /* Strong green glow */ - inset 0 0 8px rgba(255, 255, 255, 0.3); -} - -.mesh-node-marker.stale { - background: #888888; - border-color: #aaaaaa; - opacity: 0.8; - box-shadow: - 0 2px 6px rgba(0, 0, 0, 0.4), - 0 0 8px 2px rgba(136, 136, 136, 0.3); /* Subtle glow for stale */ -} - -/* ============================================ - MESSAGES SECTION - ============================================ */ -.mesh-messages-section { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - overflow: hidden; - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; - min-height: 400px; -} - -/* ============================================ - CONNECTION STATUS - ============================================ */ -.mesh-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.mesh-status-dot.disconnected { - background: var(--accent-red, #ff3366); -} - -.mesh-status-dot.connecting { - background: var(--accent-yellow, #ffc107); - animation: pulse-status 1s ease-in-out infinite; -} - -.mesh-status-dot.connected { - background: var(--accent-green, #22c55e); -} - -@keyframes pulse-status { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -/* ============================================ - NODE INFO PANEL - ============================================ */ -.mesh-node-info { - display: flex; - flex-direction: column; - gap: 8px; - background: rgba(0, 0, 0, 0.2); - border-radius: 4px; - padding: 10px; -} - -.mesh-node-row { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 11px; -} - -.mesh-node-label { - color: var(--text-dim); - text-transform: uppercase; - font-size: 9px; - letter-spacing: 0.05em; -} - -.mesh-node-value { - color: var(--text-primary); - font-family: 'JetBrains Mono', monospace; -} - -.mesh-node-id { - color: var(--accent-cyan); -} - -/* ============================================ - CHANNEL LIST - ============================================ */ -.mesh-channel-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 4px; - margin-bottom: 6px; - transition: all 0.15s ease; -} - -.mesh-channel-item:hover { - border-color: var(--border-light); -} - -.mesh-channel-item.disabled { - opacity: 0.5; -} - -.mesh-channel-info { - display: flex; - align-items: center; - gap: 10px; - flex: 1; - min-width: 0; -} - -.mesh-channel-index { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 600; - color: var(--text-dim); - background: var(--bg-primary); - padding: 2px 6px; - border-radius: 3px; - flex-shrink: 0; -} - -.mesh-channel-name { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.mesh-channel-badges { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.mesh-channel-badge { - font-family: 'JetBrains Mono', monospace; - font-size: 8px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; - padding: 2px 6px; - border-radius: 3px; -} - -.mesh-badge-primary { - background: rgba(74, 158, 255, 0.15); - color: var(--accent-cyan); - border: 1px solid rgba(74, 158, 255, 0.3); -} - -.mesh-badge-secondary { - background: rgba(136, 136, 136, 0.15); - color: var(--text-secondary); - border: 1px solid rgba(136, 136, 136, 0.3); -} - -.mesh-badge-encrypted { - background: rgba(34, 197, 94, 0.15); - color: var(--accent-green); - border: 1px solid rgba(34, 197, 94, 0.3); -} - -.mesh-badge-unencrypted { - background: rgba(255, 51, 102, 0.15); - color: var(--accent-red, #ff3366); - border: 1px solid rgba(255, 51, 102, 0.3); -} - -.mesh-channel-configure { - font-size: 10px; - color: var(--text-secondary); - background: transparent; - border: 1px solid var(--border-color); - padding: 4px 8px; - border-radius: 3px; - cursor: pointer; - transition: all 0.15s ease; - flex-shrink: 0; -} - -.mesh-channel-configure:hover { - color: var(--text-primary); - border-color: var(--border-light); - background: var(--bg-primary); -} - -/* ============================================ - MESSAGE FEED CONTAINER - ============================================ */ -.mesh-messages-container { - display: flex; - flex-direction: column; - gap: 16px; - padding: 16px; - min-height: 0; - flex: 1; - overflow-y: auto; -} - -.mesh-messages-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; -} - -.mesh-messages-title { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 10px; -} - -.mesh-messages-title svg { - color: var(--accent-cyan); -} - -.mesh-messages-filter { - display: flex; - align-items: center; - gap: 8px; -} - -.mesh-messages-filter select { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - padding: 6px 10px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); -} - -.mesh-messages-list { - display: flex; - flex-direction: column; - gap: 8px; - overflow-y: auto; - flex: 1; - min-height: 0; - padding: 12px; -} - -/* ============================================ - MESSAGE CARD - ============================================ */ -.mesh-message-card { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-left: 3px solid var(--accent-cyan); - border-radius: 4px; - padding: 12px 14px; - transition: all 0.15s ease; -} - -.mesh-message-card:hover { - border-color: var(--border-light); - border-left-color: var(--accent-cyan); -} - -.mesh-message-card.text-message { - border-left-color: var(--accent-cyan); -} - -.mesh-message-card.position-message { - border-left-color: var(--accent-green); -} - -.mesh-message-card.telemetry-message { - border-left-color: var(--accent-purple, #a855f7); -} - -.mesh-message-card.nodeinfo-message { - border-left-color: var(--accent-orange); -} - -.mesh-message-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - flex-wrap: wrap; - gap: 8px; -} - -.mesh-message-route { - display: flex; - align-items: center; - gap: 6px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; -} - -.mesh-message-from { - color: var(--accent-cyan); - font-weight: 600; -} - -.mesh-message-arrow { - color: var(--text-dim); -} - -.mesh-message-to { - color: var(--text-secondary); -} - -.mesh-message-to.broadcast { - color: var(--accent-yellow); -} - -.mesh-message-meta { - display: flex; - align-items: center; - gap: 10px; - font-size: 10px; -} - -.mesh-message-channel { - font-family: 'JetBrains Mono', monospace; - background: var(--bg-secondary); - padding: 2px 6px; - border-radius: 3px; - color: var(--text-secondary); -} - -.mesh-message-time { - color: var(--text-dim); - font-family: 'JetBrains Mono', monospace; -} - -.mesh-message-body { - font-size: 12px; - color: var(--text-primary); - line-height: 1.5; - word-break: break-word; -} - -.mesh-message-body.app-type { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-secondary); - background: var(--bg-secondary); - padding: 6px 10px; - border-radius: 4px; -} - -.mesh-message-signal { - display: flex; - align-items: center; - gap: 12px; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-color); -} - -.mesh-signal-item { - display: flex; - align-items: center; - gap: 4px; - font-family: 'JetBrains Mono', monospace; - font-size: 10px; -} - -.mesh-signal-label { - color: var(--text-dim); - text-transform: uppercase; -} - -.mesh-signal-value { - font-weight: 600; -} - -.mesh-signal-value.rssi { - color: var(--accent-cyan); -} - -.mesh-signal-value.snr { - color: var(--accent-green); -} - -.mesh-signal-value.snr.poor { - color: var(--accent-orange); -} - -.mesh-signal-value.snr.bad { - color: var(--accent-red, #ff3366); -} - -/* ============================================ - MESSAGE STATUS (Pending/Sent/Failed) - ============================================ */ -.mesh-message-card.pending { - opacity: 0.7; - border-left-color: var(--text-dim); -} - -.mesh-message-card.pending .mesh-message-from { - color: var(--text-secondary); -} - -.mesh-message-card.failed { - border-left-color: var(--accent-red, #ff3366); - background: rgba(255, 51, 102, 0.05); -} - -.mesh-message-card.sent { - border-left-color: var(--accent-green); -} - -.mesh-message-status { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - padding: 2px 6px; - border-radius: 3px; - margin-left: 8px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.mesh-message-status.sending { - background: var(--bg-secondary); - color: var(--text-dim); - animation: pulse-sending 1.5s ease-in-out infinite; -} - -.mesh-message-status.failed { - background: rgba(255, 51, 102, 0.15); - color: var(--accent-red, #ff3366); -} - -@keyframes pulse-sending { - 0%, 100% { opacity: 0.5; } - 50% { opacity: 1; } -} - -/* Send button sending state */ -.mesh-compose-send.sending { - opacity: 0.6; - cursor: wait; -} - -/* ============================================ - EMPTY STATE - ============================================ */ -.mesh-messages-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 60px 20px; - text-align: center; - color: var(--text-dim); -} - -.mesh-messages-empty svg { - width: 48px; - height: 48px; - opacity: 0.3; - margin-bottom: 12px; -} - -.mesh-messages-empty p { - font-size: 13px; - margin-top: 8px; -} - -/* ============================================ - MODAL FORM STYLING - ============================================ */ -#meshChannelModal .form-group label { - display: block; -} - -#meshChannelModal input[type="text"], -#meshChannelModal select { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - padding: 10px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); -} - -#meshChannelModal input[type="text"]:focus, -#meshChannelModal select:focus { - outline: none; - border-color: var(--accent-cyan); -} - -/* ============================================ - MESSAGE COMPOSE - ============================================ */ -.mesh-compose { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 12px; - margin-top: 16px; - flex-shrink: 0; -} - -.mesh-compose-header { - display: flex; - gap: 8px; - margin-bottom: 8px; -} - -.mesh-compose-channel { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - padding: 6px 10px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); - min-width: 70px; - cursor: pointer; -} - -.mesh-compose-channel:focus { - outline: none; - border-color: var(--accent-cyan); -} - -.mesh-compose-to { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - padding: 6px 10px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); - flex: 1; - min-width: 100px; -} - -.mesh-compose-to:focus { - outline: none; - border-color: var(--accent-cyan); -} - -.mesh-compose-to::placeholder { - color: var(--text-dim); -} - -.mesh-compose-body { - display: flex; - gap: 8px; -} - -.mesh-compose-input { - flex: 1; - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - padding: 10px 12px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); -} - -.mesh-compose-input:focus { - outline: none; - border-color: var(--accent-cyan); -} - -.mesh-compose-input::placeholder { - color: var(--text-dim); -} - -.mesh-compose-send { - background: var(--accent-cyan); - border: none; - border-radius: 4px; - padding: 10px 14px; - cursor: pointer; - color: #000; - transition: all 0.15s ease; - display: flex; - align-items: center; - justify-content: center; -} - -.mesh-compose-send:hover { - background: var(--accent-green); - transform: scale(1.05); -} - -.mesh-compose-send:active { - transform: scale(0.98); -} - -.mesh-compose-send:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - -.mesh-compose-hint { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-dim); - margin-top: 6px; - text-align: right; -} - -/* ============================================ - RESPONSIVE - ============================================ */ -@media (max-width: 1024px) { - .mesh-main-row { - flex-direction: column; - overflow-y: auto; - } - - .mesh-map-section, - .mesh-messages-section { - flex: none; - min-height: 300px; - } -} - -@media (max-width: 768px) { - .mesh-map-section { - min-height: 200px; - } - - .mesh-messages-section { - min-height: 250px; - } - - .mesh-map { - min-height: 180px; - } - - .mesh-map-header { - flex-direction: column; - align-items: flex-start; - gap: 6px; - } - - .mesh-channel-item { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .mesh-channel-badges { - width: 100%; - justify-content: flex-start; - } - - .mesh-channel-configure { - width: 100%; - text-align: center; - min-height: 36px; - } - - .mesh-message-header { - flex-direction: column; - align-items: flex-start; - } - - .mesh-message-meta { - width: 100%; - justify-content: space-between; - } - - .mesh-compose-header { - flex-direction: column; - } - - .mesh-compose-to { - width: 100%; - } -} - -@media (max-width: 480px) { - .mesh-messages-container { - padding: 8px; - } - - .mesh-message-card { - padding: 10px; - } - - .mesh-message-signal { - flex-wrap: wrap; - } -} - -/* Touch device compliance */ -@media (pointer: coarse) { - .mesh-channel-configure { - min-height: 44px; - padding: 8px 12px; - } - - .mesh-compose-send { - min-width: 44px; - min-height: 44px; - } - - .mesh-compose-input { - min-height: 44px; - } -} - -/* ============================================ - TRACEROUTE BUTTON IN POPUP - ============================================ */ -.mesh-traceroute-btn { - display: block; - width: 100%; - margin-top: 10px; - padding: 8px 12px; - background: var(--accent-cyan); - border: none; - border-radius: 4px; - color: #000; - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - cursor: pointer; - transition: all 0.15s ease; -} - -.mesh-traceroute-btn:hover { - background: var(--accent-green); - transform: scale(1.02); -} - -/* ============================================ - TRACEROUTE MODAL CONTENT - ============================================ */ -.mesh-traceroute-content { - min-height: 100px; -} - -.mesh-traceroute-loading { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 40px 20px; - color: var(--text-secondary); -} - -.mesh-traceroute-spinner { - width: 32px; - height: 32px; - border: 3px solid var(--border-color); - border-top-color: var(--accent-cyan); - border-radius: 50%; - animation: mesh-spin 1s linear infinite; - margin-bottom: 16px; -} - -@keyframes mesh-spin { - to { transform: rotate(360deg); } -} - -.mesh-traceroute-error { - padding: 16px; - background: rgba(255, 51, 102, 0.1); - border: 1px solid var(--accent-red, #ff3366); - border-radius: 6px; - color: var(--accent-red, #ff3366); - font-size: 12px; -} - -.mesh-traceroute-section { - margin-bottom: 16px; -} - -.mesh-traceroute-label { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 600; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 10px; -} - -.mesh-traceroute-path { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - padding: 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; -} - -.mesh-traceroute-hop { - display: flex; - flex-direction: column; - align-items: center; - padding: 10px 14px; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 6px; - min-width: 70px; -} - -.mesh-traceroute-hop-node { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--accent-cyan); - margin-bottom: 4px; -} - -.mesh-traceroute-hop-id { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - color: var(--text-dim); - margin-bottom: 6px; -} - -.mesh-traceroute-snr { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 600; - padding: 2px 8px; - border-radius: 10px; -} - -.mesh-traceroute-snr.snr-good { - background: rgba(34, 197, 94, 0.15); - color: var(--accent-green); -} - -.mesh-traceroute-snr.snr-ok { - background: rgba(74, 158, 255, 0.15); - color: var(--accent-cyan); -} - -.mesh-traceroute-snr.snr-poor { - background: rgba(255, 193, 7, 0.15); - color: var(--accent-orange); -} - -.mesh-traceroute-snr.snr-bad { - background: rgba(255, 51, 102, 0.15); - color: var(--accent-red, #ff3366); -} - -.mesh-traceroute-arrow { - font-size: 18px; - color: var(--text-dim); - font-weight: bold; -} - -.mesh-traceroute-timestamp { - margin-top: 12px; - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-dim); - text-align: right; -} - -/* Responsive traceroute path */ -@media (max-width: 600px) { - .mesh-traceroute-path { - flex-direction: column; - } - - .mesh-traceroute-hop { - width: 100%; - } - - .mesh-traceroute-arrow { - transform: rotate(90deg); - } -} - -/* ============================================ - NODE POPUP ACTION BUTTONS - ============================================ */ -.mesh-position-btn, -.mesh-telemetry-btn { - padding: 6px 10px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - font-weight: 600; - text-transform: uppercase; - cursor: pointer; - transition: all 0.15s ease; -} - -.mesh-position-btn:hover, -.mesh-telemetry-btn:hover { - background: var(--accent-cyan); - color: #000; - border-color: var(--accent-cyan); -} - -/* ============================================ - QR CODE BUTTON - ============================================ */ -.mesh-qr-btn { - padding: 4px 8px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; -} - -.mesh-qr-btn:hover { - background: var(--accent-cyan); - color: #000; - border-color: var(--accent-cyan); -} - -/* ============================================ - TELEMETRY CHARTS - ============================================ */ -.mesh-telemetry-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-color); -} - -.mesh-telemetry-chart { - margin-bottom: 20px; -} - -.mesh-telemetry-chart-title { - display: flex; - justify-content: space-between; - align-items: center; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 8px; -} - -.mesh-telemetry-current { - font-size: 14px; - color: var(--accent-cyan); -} - -.mesh-telemetry-svg { - width: 100%; - height: 100px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; -} - -.mesh-chart-line { - stroke: var(--accent-cyan); - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; -} - -.mesh-chart-grid { - stroke: var(--border-color); - stroke-width: 1; -} - -.mesh-chart-label { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - fill: var(--text-dim); -} - -/* ============================================ - NETWORK TOPOLOGY - ============================================ */ -.mesh-network-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.mesh-network-node { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 12px; -} - -.mesh-network-node-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - padding-bottom: 8px; - border-bottom: 1px solid var(--border-color); -} - -.mesh-network-node-id { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--accent-cyan); -} - -.mesh-network-node-count { - font-size: 11px; - color: var(--text-dim); -} - -.mesh-network-neighbors { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.mesh-network-neighbor { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.mesh-network-neighbor-id { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-secondary); -} - -.mesh-network-neighbor-snr { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - font-weight: 600; - padding: 2px 6px; - border-radius: 10px; -} - -.mesh-network-neighbor-snr.snr-good { - background: rgba(34, 197, 94, 0.15); - color: var(--accent-green); -} - -.mesh-network-neighbor-snr.snr-ok { - background: rgba(74, 158, 255, 0.15); - color: var(--accent-cyan); -} - -.mesh-network-neighbor-snr.snr-poor { - background: rgba(255, 193, 7, 0.15); - color: var(--accent-orange); -} - -.mesh-network-neighbor-snr.snr-bad { - background: rgba(255, 51, 102, 0.15); - color: var(--accent-red, #ff3366); -} - -/* ============================================ - FIRMWARE BADGES - ============================================ */ -.mesh-badge { - display: inline-block; - padding: 3px 8px; - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 600; - border-radius: 10px; - text-transform: uppercase; -} - -.mesh-badge-success { - background: rgba(34, 197, 94, 0.15); - color: var(--accent-green); -} - -.mesh-badge-warning { - background: rgba(255, 193, 7, 0.15); - color: var(--accent-orange); -} +/** + * Meshtastic Mode Styles + * Mesh network monitoring interface + */ + +/* ============================================ + MODE VISIBILITY + ============================================ */ +#meshtasticMode.active { + display: block !important; +} + +/* ============================================ + MAIN SIDEBAR COLLAPSE (for Meshtastic mode) + ============================================ */ + +/* When sidebar is hidden, adjust layout */ +.main-content.mesh-sidebar-hidden { + display: flex !important; + flex-direction: column !important; +} + +.main-content.mesh-sidebar-hidden > .sidebar { + display: none !important; + width: 0 !important; + height: 0 !important; + overflow: hidden !important; +} + +.main-content.mesh-sidebar-hidden > .output-panel { + flex: 1 !important; + width: 100% !important; + max-width: 100% !important; +} + +/* Hide Sidebar Button in sidebar */ +.mesh-hide-sidebar-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 12px; + margin-bottom: 12px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + transition: all 0.15s ease; +} + +.mesh-hide-sidebar-btn:hover { + background: var(--bg-secondary); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.mesh-hide-sidebar-btn svg { + width: 14px; + height: 14px; +} + +/* When sidebar is hidden, highlight the toggle button in stats strip */ +.main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle { + background: var(--accent-cyan); + border-color: var(--accent-cyan); + color: var(--bg-primary); +} + +.main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle:hover { + background: var(--accent-blue); + border-color: var(--accent-blue); +} + +/* Sidebar toggle button in stats strip */ +.mesh-strip-sidebar-toggle { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + transition: all 0.15s ease; + pointer-events: auto; + z-index: 100; + position: relative; +} + +.mesh-strip-sidebar-toggle:hover { + background: var(--bg-secondary); + border-color: var(--border-light); + color: var(--text-primary); +} + +.mesh-strip-sidebar-toggle svg { + width: 14px; + height: 14px; + transition: transform 0.2s ease; +} + +@media (min-width: 1024px) { + .main-content.mesh-sidebar-hidden .mesh-strip-sidebar-toggle svg { + transform: rotate(180deg); + } +} + +/* ============================================ + COLLAPSIBLE SIDEBAR CONTENT + ============================================ */ +.mesh-sidebar-toggle { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + margin-bottom: 12px; + transition: all 0.15s ease; +} + +.mesh-sidebar-toggle:hover { + background: var(--bg-card); + border-color: var(--border-light); +} + +.mesh-sidebar-toggle-icon { + font-size: 10px; + color: var(--text-dim); + transition: transform 0.2s ease; +} + +.mesh-sidebar-toggle-text { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.mesh-sidebar-content { + display: block; + transition: all 0.2s ease; +} + +/* Collapsed state */ +#meshtasticMode.mesh-sidebar-collapsed .mesh-sidebar-content { + display: none; +} + +#meshtasticMode.mesh-sidebar-collapsed .mesh-sidebar-toggle-icon { + transform: rotate(0deg); +} + +#meshtasticMode:not(.mesh-sidebar-collapsed) .mesh-sidebar-toggle-icon { + transform: rotate(90deg); +} + +/* ============================================ + MAIN VISUALS CONTAINER + ============================================ */ +.mesh-visuals-container { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + min-height: 0; + flex: 1; + overflow: hidden; +} + +/* ============================================ + MAIN ROW (Map + Messages side by side) + ============================================ */ +.mesh-main-row { + display: flex; + flex-direction: row; + gap: 16px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ============================================ + STATS STRIP (Compact Header Bar) + ============================================ */ +.mesh-stats-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + flex-wrap: wrap; +} + +.mesh-strip-group { + display: flex; + align-items: center; + gap: 12px; +} + +.mesh-strip-status { + display: flex; + align-items: center; + gap: 6px; +} + +.mesh-strip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.mesh-strip-dot.disconnected { + background: var(--text-dim); +} + +.mesh-strip-dot.connecting { + background: var(--accent-yellow); + animation: pulse 1s infinite; +} + +.mesh-strip-dot.connected { + background: var(--accent-green); + box-shadow: 0 0 6px var(--accent-green); +} + +.mesh-strip-status-text { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + text-transform: uppercase; +} + +.mesh-strip-select { + font-family: var(--font-mono); + font-size: 10px; + padding: 4px 8px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + max-width: 120px; +} + +.mesh-strip-input { + font-family: var(--font-mono); + font-size: 10px; + padding: 4px 8px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + max-width: 140px; +} + +.mesh-strip-input::placeholder { + color: var(--text-secondary); + opacity: 0.7; +} + +.mesh-strip-input:focus { + outline: none; + border-color: var(--accent-cyan); +} + +.mesh-strip-btn { + font-family: var(--font-mono); + font-size: 10px; + padding: 5px 12px; + border: none; + border-radius: 4px; + cursor: pointer; + text-transform: uppercase; + font-weight: 600; + transition: all 0.15s ease; +} + +.mesh-strip-btn.connect { + background: var(--accent-cyan); + color: var(--bg-primary); +} + +.mesh-strip-btn.connect:hover { + background: var(--accent-cyan-bright, #00d4ff); +} + +.mesh-strip-btn.disconnect { + background: var(--accent-red, #ff3366); + color: white; +} + +.mesh-strip-btn.disconnect:hover { + background: #ff1a53; +} + +.mesh-strip-divider { + width: 1px; + height: 24px; + background: var(--border-color); +} + +.mesh-strip-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 50px; +} + +.mesh-strip-value { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100px; +} + +.mesh-strip-value.accent-cyan { + color: var(--accent-cyan); +} + +.mesh-strip-value.accent-green { + color: var(--accent-green); +} + +.mesh-strip-id { + font-size: 10px; + color: var(--accent-cyan); +} + +.mesh-strip-label { + font-family: var(--font-mono); + font-size: 8px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +@media (max-width: 768px) { + .mesh-stats-strip { + padding: 8px 12px; + gap: 8px; + } + + .mesh-strip-group { + gap: 8px; + } + + .mesh-strip-divider { + display: none; + } + + .mesh-strip-stat { + min-width: 40px; + } + + .mesh-strip-value { + font-size: 11px; + max-width: 60px; + } +} + +/* ============================================ + NODE MAP SECTION + ============================================ */ +.mesh-map-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 400px; +} + +.mesh-map-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); +} + +.mesh-map-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.mesh-map-title svg { + color: var(--accent-cyan); +} + +.mesh-map-stats { + display: flex; + gap: 16px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); +} + +.mesh-map-stats span:last-child { + color: var(--accent-green); +} + +.mesh-map { + flex: 1; + min-height: 0; + background: var(--bg-primary); +} + +/* Leaflet map overrides for dark theme */ +.mesh-map .leaflet-container { + background: var(--bg-primary); +} + +/* Override Leaflet's default div-icon styling for mesh markers */ +.mesh-marker-wrapper.leaflet-div-icon { + background: transparent; + border: none; +} + +.mesh-map .leaflet-popup-content-wrapper { + background: var(--bg-card); + color: var(--text-primary); + border-radius: 6px; + border: 1px solid var(--border-color); +} + +.mesh-map .leaflet-popup-tip { + background: var(--bg-card); +} + +.mesh-map .leaflet-popup-content { + font-family: var(--font-mono); + font-size: 11px; + margin: 10px 12px; +} + +/* Custom node marker - high visibility on dark maps */ +.mesh-node-marker { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: #00ffff; /* Bright cyan for maximum visibility */ + border: 3px solid #ffffff; + border-radius: 50%; + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.6), + 0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */ + inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */ + color: #000; + font-family: var(--font-mono); + font-size: 11px; + font-weight: bold; + text-shadow: 0 0 2px rgba(255, 255, 255, 0.8); +} + +.mesh-node-marker.local { + background: #00ff88; /* Bright green for local node */ + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.6), + 0 0 20px 8px rgba(0, 255, 136, 0.7), /* Strong green glow */ + inset 0 0 8px rgba(255, 255, 255, 0.3); +} + +.mesh-node-marker.stale { + background: #888888; + border-color: #aaaaaa; + opacity: 0.8; + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.4), + 0 0 8px 2px rgba(136, 136, 136, 0.3); /* Subtle glow for stale */ +} + +/* ============================================ + MESSAGES SECTION + ============================================ */ +.mesh-messages-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 400px; +} + +/* ============================================ + CONNECTION STATUS + ============================================ */ +.mesh-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.mesh-status-dot.disconnected { + background: var(--accent-red, #ff3366); +} + +.mesh-status-dot.connecting { + background: var(--accent-yellow, #ffc107); + animation: pulse-status 1s ease-in-out infinite; +} + +.mesh-status-dot.connected { + background: var(--accent-green, #22c55e); +} + +@keyframes pulse-status { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ============================================ + NODE INFO PANEL + ============================================ */ +.mesh-node-info { + display: flex; + flex-direction: column; + gap: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + padding: 10px; +} + +.mesh-node-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; +} + +.mesh-node-label { + color: var(--text-dim); + text-transform: uppercase; + font-size: 9px; + letter-spacing: 0.05em; +} + +.mesh-node-value { + color: var(--text-primary); + font-family: var(--font-mono); +} + +.mesh-node-id { + color: var(--accent-cyan); +} + +/* ============================================ + CHANNEL LIST + ============================================ */ +.mesh-channel-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + margin-bottom: 6px; + transition: all 0.15s ease; +} + +.mesh-channel-item:hover { + border-color: var(--border-light); +} + +.mesh-channel-item.disabled { + opacity: 0.5; +} + +.mesh-channel-info { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} + +.mesh-channel-index { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--text-dim); + background: var(--bg-primary); + padding: 2px 6px; + border-radius: 3px; + flex-shrink: 0; +} + +.mesh-channel-name { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mesh-channel-badges { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.mesh-channel-badge { + font-family: var(--font-mono); + font-size: 8px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 2px 6px; + border-radius: 3px; +} + +.mesh-badge-primary { + background: rgba(74, 158, 255, 0.15); + color: var(--accent-cyan); + border: 1px solid rgba(74, 158, 255, 0.3); +} + +.mesh-badge-secondary { + background: rgba(136, 136, 136, 0.15); + color: var(--text-secondary); + border: 1px solid rgba(136, 136, 136, 0.3); +} + +.mesh-badge-encrypted { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.mesh-badge-unencrypted { + background: rgba(255, 51, 102, 0.15); + color: var(--accent-red, #ff3366); + border: 1px solid rgba(255, 51, 102, 0.3); +} + +.mesh-channel-configure { + font-size: 10px; + color: var(--text-secondary); + background: transparent; + border: 1px solid var(--border-color); + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.mesh-channel-configure:hover { + color: var(--text-primary); + border-color: var(--border-light); + background: var(--bg-primary); +} + +/* ============================================ + MESSAGE FEED CONTAINER + ============================================ */ +.mesh-messages-container { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + min-height: 0; + flex: 1; + overflow-y: auto; +} + +.mesh-messages-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.mesh-messages-title { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 10px; +} + +.mesh-messages-title svg { + color: var(--accent-cyan); +} + +.mesh-messages-filter { + display: flex; + align-items: center; + gap: 8px; +} + +.mesh-messages-filter select { + font-family: var(--font-mono); + font-size: 10px; + padding: 6px 10px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); +} + +.mesh-messages-list { + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + flex: 1; + min-height: 0; + padding: 12px; +} + +/* ============================================ + MESSAGE CARD + ============================================ */ +.mesh-message-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-left: 3px solid var(--accent-cyan); + border-radius: 4px; + padding: 12px 14px; + transition: all 0.15s ease; +} + +.mesh-message-card:hover { + border-color: var(--border-light); + border-left-color: var(--accent-cyan); +} + +.mesh-message-card.text-message { + border-left-color: var(--accent-cyan); +} + +.mesh-message-card.position-message { + border-left-color: var(--accent-green); +} + +.mesh-message-card.telemetry-message { + border-left-color: var(--accent-purple, #a855f7); +} + +.mesh-message-card.nodeinfo-message { + border-left-color: var(--accent-orange); +} + +.mesh-message-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + flex-wrap: wrap; + gap: 8px; +} + +.mesh-message-route { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 11px; +} + +.mesh-message-from { + color: var(--accent-cyan); + font-weight: 600; +} + +.mesh-message-arrow { + color: var(--text-dim); +} + +.mesh-message-to { + color: var(--text-secondary); +} + +.mesh-message-to.broadcast { + color: var(--accent-yellow); +} + +.mesh-message-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 10px; +} + +.mesh-message-channel { + font-family: var(--font-mono); + background: var(--bg-secondary); + padding: 2px 6px; + border-radius: 3px; + color: var(--text-secondary); +} + +.mesh-message-time { + color: var(--text-dim); + font-family: var(--font-mono); +} + +.mesh-message-body { + font-size: 12px; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; +} + +.mesh-message-body.app-type { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-secondary); + padding: 6px 10px; + border-radius: 4px; +} + +.mesh-message-signal { + display: flex; + align-items: center; + gap: 12px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); +} + +.mesh-signal-item { + display: flex; + align-items: center; + gap: 4px; + font-family: var(--font-mono); + font-size: 10px; +} + +.mesh-signal-label { + color: var(--text-dim); + text-transform: uppercase; +} + +.mesh-signal-value { + font-weight: 600; +} + +.mesh-signal-value.rssi { + color: var(--accent-cyan); +} + +.mesh-signal-value.snr { + color: var(--accent-green); +} + +.mesh-signal-value.snr.poor { + color: var(--accent-orange); +} + +.mesh-signal-value.snr.bad { + color: var(--accent-red, #ff3366); +} + +/* ============================================ + MESSAGE STATUS (Pending/Sent/Failed) + ============================================ */ +.mesh-message-card.pending { + opacity: 0.7; + border-left-color: var(--text-dim); +} + +.mesh-message-card.pending .mesh-message-from { + color: var(--text-secondary); +} + +.mesh-message-card.failed { + border-left-color: var(--accent-red, #ff3366); + background: rgba(255, 51, 102, 0.05); +} + +.mesh-message-card.sent { + border-left-color: var(--accent-green); +} + +.mesh-message-status { + font-family: var(--font-mono); + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + margin-left: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.mesh-message-status.sending { + background: var(--bg-secondary); + color: var(--text-dim); + animation: pulse-sending 1.5s ease-in-out infinite; +} + +.mesh-message-status.failed { + background: rgba(255, 51, 102, 0.15); + color: var(--accent-red, #ff3366); +} + +@keyframes pulse-sending { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} + +/* Send button sending state */ +.mesh-compose-send.sending { + opacity: 0.6; + cursor: wait; +} + +/* ============================================ + EMPTY STATE + ============================================ */ +.mesh-messages-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + color: var(--text-dim); +} + +.mesh-messages-empty svg { + width: 48px; + height: 48px; + opacity: 0.3; + margin-bottom: 12px; +} + +.mesh-messages-empty p { + font-size: 13px; + margin-top: 8px; +} + +/* ============================================ + MODAL FORM STYLING + ============================================ */ +#meshChannelModal .form-group label { + display: block; +} + +#meshChannelModal input[type="text"], +#meshChannelModal select { + font-family: var(--font-mono); + font-size: 12px; + padding: 10px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); +} + +#meshChannelModal input[type="text"]:focus, +#meshChannelModal select:focus { + outline: none; + border-color: var(--accent-cyan); +} + +/* ============================================ + MESSAGE COMPOSE + ============================================ */ +.mesh-compose { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + margin-top: 16px; + flex-shrink: 0; +} + +.mesh-compose-header { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.mesh-compose-channel { + font-family: var(--font-mono); + font-size: 11px; + padding: 6px 10px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + min-width: 70px; + cursor: pointer; +} + +.mesh-compose-channel:focus { + outline: none; + border-color: var(--accent-cyan); +} + +.mesh-compose-to { + font-family: var(--font-mono); + font-size: 11px; + padding: 6px 10px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + flex: 1; + min-width: 100px; +} + +.mesh-compose-to:focus { + outline: none; + border-color: var(--accent-cyan); +} + +.mesh-compose-to::placeholder { + color: var(--text-dim); +} + +.mesh-compose-body { + display: flex; + gap: 8px; +} + +.mesh-compose-input { + flex: 1; + font-family: var(--font-mono); + font-size: 12px; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); +} + +.mesh-compose-input:focus { + outline: none; + border-color: var(--accent-cyan); +} + +.mesh-compose-input::placeholder { + color: var(--text-dim); +} + +.mesh-compose-send { + background: var(--accent-cyan); + border: none; + border-radius: 4px; + padding: 10px 14px; + cursor: pointer; + color: #000; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.mesh-compose-send:hover { + background: var(--accent-green); + transform: scale(1.05); +} + +.mesh-compose-send:active { + transform: scale(0.98); +} + +.mesh-compose-send:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.mesh-compose-hint { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + margin-top: 6px; + text-align: right; +} + +/* ============================================ + RESPONSIVE + ============================================ */ +@media (max-width: 1024px) { + .mesh-main-row { + flex-direction: column; + overflow-y: auto; + } + + .mesh-map-section, + .mesh-messages-section { + flex: none; + min-height: 300px; + } +} + +@media (max-width: 768px) { + .mesh-map-section { + min-height: 200px; + } + + .mesh-messages-section { + min-height: 250px; + } + + .mesh-map { + min-height: 180px; + } + + .mesh-map-header { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + .mesh-channel-item { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .mesh-channel-badges { + width: 100%; + justify-content: flex-start; + } + + .mesh-channel-configure { + width: 100%; + text-align: center; + min-height: 36px; + } + + .mesh-message-header { + flex-direction: column; + align-items: flex-start; + } + + .mesh-message-meta { + width: 100%; + justify-content: space-between; + } + + .mesh-compose-header { + flex-direction: column; + } + + .mesh-compose-to { + width: 100%; + } +} + +@media (max-width: 480px) { + .mesh-messages-container { + padding: 8px; + } + + .mesh-message-card { + padding: 10px; + } + + .mesh-message-signal { + flex-wrap: wrap; + } +} + +/* Touch device compliance */ +@media (pointer: coarse) { + .mesh-channel-configure { + min-height: 44px; + padding: 8px 12px; + } + + .mesh-compose-send { + min-width: 44px; + min-height: 44px; + } + + .mesh-compose-input { + min-height: 44px; + } +} + +/* ============================================ + TRACEROUTE BUTTON IN POPUP + ============================================ */ +.mesh-traceroute-btn { + display: block; + width: 100%; + margin-top: 10px; + padding: 8px 12px; + background: var(--accent-cyan); + border: none; + border-radius: 4px; + color: #000; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s ease; +} + +.mesh-traceroute-btn:hover { + background: var(--accent-green); + transform: scale(1.02); +} + +/* ============================================ + TRACEROUTE MODAL CONTENT + ============================================ */ +.mesh-traceroute-content { + min-height: 100px; +} + +.mesh-traceroute-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-secondary); +} + +.mesh-traceroute-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-color); + border-top-color: var(--accent-cyan); + border-radius: 50%; + animation: mesh-spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes mesh-spin { + to { transform: rotate(360deg); } +} + +.mesh-traceroute-error { + padding: 16px; + background: rgba(255, 51, 102, 0.1); + border: 1px solid var(--accent-red, #ff3366); + border-radius: 6px; + color: var(--accent-red, #ff3366); + font-size: 12px; +} + +.mesh-traceroute-section { + margin-bottom: 16px; +} + +.mesh-traceroute-label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} + +.mesh-traceroute-path { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; +} + +.mesh-traceroute-hop { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + min-width: 70px; +} + +.mesh-traceroute-hop-node { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--accent-cyan); + margin-bottom: 4px; +} + +.mesh-traceroute-hop-id { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + margin-bottom: 6px; +} + +.mesh-traceroute-snr { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; +} + +.mesh-traceroute-snr.snr-good { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); +} + +.mesh-traceroute-snr.snr-ok { + background: rgba(74, 158, 255, 0.15); + color: var(--accent-cyan); +} + +.mesh-traceroute-snr.snr-poor { + background: rgba(255, 193, 7, 0.15); + color: var(--accent-orange); +} + +.mesh-traceroute-snr.snr-bad { + background: rgba(255, 51, 102, 0.15); + color: var(--accent-red, #ff3366); +} + +.mesh-traceroute-arrow { + font-size: 18px; + color: var(--text-dim); + font-weight: bold; +} + +.mesh-traceroute-timestamp { + margin-top: 12px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-align: right; +} + +/* Responsive traceroute path */ +@media (max-width: 600px) { + .mesh-traceroute-path { + flex-direction: column; + } + + .mesh-traceroute-hop { + width: 100%; + } + + .mesh-traceroute-arrow { + transform: rotate(90deg); + } +} + +/* ============================================ + NODE POPUP ACTION BUTTONS + ============================================ */ +.mesh-position-btn, +.mesh-telemetry-btn { + padding: 6px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s ease; +} + +.mesh-position-btn:hover, +.mesh-telemetry-btn:hover { + background: var(--accent-cyan); + color: #000; + border-color: var(--accent-cyan); +} + +/* ============================================ + QR CODE BUTTON + ============================================ */ +.mesh-qr-btn { + padding: 4px 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.mesh-qr-btn:hover { + background: var(--accent-cyan); + color: #000; + border-color: var(--accent-cyan); +} + +/* ============================================ + TELEMETRY CHARTS + ============================================ */ +.mesh-telemetry-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.mesh-telemetry-chart { + margin-bottom: 20px; +} + +.mesh-telemetry-chart-title { + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.mesh-telemetry-current { + font-size: 14px; + color: var(--accent-cyan); +} + +.mesh-telemetry-svg { + width: 100%; + height: 100px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; +} + +.mesh-chart-line { + stroke: var(--accent-cyan); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.mesh-chart-grid { + stroke: var(--border-color); + stroke-width: 1; +} + +.mesh-chart-label { + font-family: var(--font-mono); + font-size: 9px; + fill: var(--text-dim); +} + +/* ============================================ + NETWORK TOPOLOGY + ============================================ */ +.mesh-network-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mesh-network-node { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; +} + +.mesh-network-node-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); +} + +.mesh-network-node-id { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--accent-cyan); +} + +.mesh-network-node-count { + font-size: 11px; + color: var(--text-dim); +} + +.mesh-network-neighbors { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mesh-network-neighbor { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.mesh-network-neighbor-id { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); +} + +.mesh-network-neighbor-snr { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + padding: 2px 6px; + border-radius: 10px; +} + +.mesh-network-neighbor-snr.snr-good { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); +} + +.mesh-network-neighbor-snr.snr-ok { + background: rgba(74, 158, 255, 0.15); + color: var(--accent-cyan); +} + +.mesh-network-neighbor-snr.snr-poor { + background: rgba(255, 193, 7, 0.15); + color: var(--accent-orange); +} + +.mesh-network-neighbor-snr.snr-bad { + background: rgba(255, 51, 102, 0.15); + color: var(--accent-red, #ff3366); +} + +/* ============================================ + FIRMWARE BADGES + ============================================ */ +.mesh-badge { + display: inline-block; + padding: 3px 8px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + border-radius: 10px; + text-transform: uppercase; +} + +.mesh-badge-success { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); +} + +.mesh-badge-warning { + background: rgba(255, 193, 7, 0.15); + color: var(--accent-orange); +} diff --git a/static/css/modes/spy-stations.css b/static/css/modes/spy-stations.css index d488ecb..604ac39 100644 --- a/static/css/modes/spy-stations.css +++ b/static/css/modes/spy-stations.css @@ -27,7 +27,7 @@ } .spy-stations-title { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: var(--text-primary); @@ -101,7 +101,7 @@ } .spy-station-name { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: var(--text-primary); @@ -117,7 +117,7 @@ /* Type Badge */ .spy-station-badge { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 9px; font-weight: 600; text-transform: uppercase; @@ -173,7 +173,7 @@ } .spy-meta-mode { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; color: var(--accent-orange); } @@ -186,7 +186,7 @@ } .spy-freq-list { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; color: var(--accent-cyan); line-height: 1.6; @@ -199,7 +199,7 @@ } .spy-freq-item { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; color: var(--accent-cyan); background: var(--bg-secondary); @@ -236,7 +236,7 @@ } .spy-freq-select { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 10px; padding: 6px 8px; background: var(--bg-secondary); @@ -273,7 +273,7 @@ display: inline-flex; align-items: center; gap: 6px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; diff --git a/static/css/modes/sstv.css b/static/css/modes/sstv.css index c6f3b10..6af1ac9 100644 --- a/static/css/modes/sstv.css +++ b/static/css/modes/sstv.css @@ -1,876 +1,876 @@ -/** - * SSTV Mode Styles - * ISS Slow-Scan Television decoder interface - */ - -/* ============================================ - MODE VISIBILITY - ============================================ */ -#sstvMode.active { - display: block !important; -} - -/* ============================================ - VISUALS CONTAINER - ============================================ */ -.sstv-visuals-container { - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; - min-height: 0; - flex: 1; - height: 100%; - overflow: hidden; -} - -/* ============================================ - MAIN ROW (Live Decode + Gallery) - ============================================ */ -.sstv-main-row { - display: flex; - flex-direction: row; - gap: 12px; - flex: 1; - min-height: 0; - overflow: hidden; -} - -/* ============================================ - STATS STRIP - ============================================ */ -.sstv-stats-strip { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 14px; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - flex-wrap: wrap; - flex-shrink: 0; -} - -.sstv-strip-group { - display: flex; - align-items: center; - gap: 12px; -} - -.sstv-strip-status { - display: flex; - align-items: center; - gap: 6px; -} - -.sstv-strip-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.sstv-strip-dot.idle { - background: var(--text-dim); -} - -.sstv-strip-dot.listening { - background: var(--accent-yellow); - animation: pulse 1s infinite; -} - -.sstv-strip-dot.decoding { - background: var(--accent-cyan); - box-shadow: 0 0 6px var(--accent-cyan); - animation: pulse 0.5s infinite; -} - -.sstv-strip-status-text { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-secondary); - text-transform: uppercase; -} - -.sstv-strip-btn { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - padding: 5px 12px; - border: none; - border-radius: 4px; - cursor: pointer; - text-transform: uppercase; - font-weight: 600; - transition: all 0.15s ease; -} - -.sstv-strip-btn.start { - background: var(--accent-cyan); - color: var(--bg-primary); -} - -.sstv-strip-btn.start:hover { - background: var(--accent-cyan-bright, #00d4ff); -} - -.sstv-strip-btn.stop { - background: var(--accent-red, #ff3366); - color: white; -} - -.sstv-strip-btn.stop:hover { - background: #ff1a53; -} - -.sstv-strip-divider { - width: 1px; - height: 24px; - background: var(--border-color); -} - -.sstv-strip-stat { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - min-width: 50px; -} - -.sstv-strip-value { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--text-primary); -} - -.sstv-strip-value.accent-cyan { - color: var(--accent-cyan); -} - -.sstv-strip-label { - font-family: 'JetBrains Mono', monospace; - font-size: 8px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* Location inputs in strip */ -.sstv-strip-location { - display: flex; - align-items: center; - gap: 4px; -} - -.sstv-loc-input { - width: 70px; - padding: 4px 6px; - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-primary); - text-align: right; -} - -.sstv-loc-input:focus { - outline: none; - border-color: var(--accent-cyan); -} - -.sstv-strip-btn.gps { - display: flex; - align-items: center; - gap: 4px; - background: var(--bg-tertiary); - color: var(--text-secondary); - border: 1px solid var(--border-color); -} - -.sstv-strip-btn.gps:hover { - background: var(--accent-green); - color: #000; - border-color: var(--accent-green); -} - -.sstv-strip-btn.update-tle { - display: flex; - align-items: center; - gap: 4px; - background: var(--bg-tertiary); - color: var(--text-secondary); - border: 1px solid var(--border-color); -} - -.sstv-strip-btn.update-tle:hover { - background: var(--accent-orange); - color: #000; - border-color: var(--accent-orange); -} - -/* ============================================ - LIVE DECODE SECTION - ============================================ */ -.sstv-live-section { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - overflow: hidden; - display: flex; - flex-direction: column; - flex: 1; - min-width: 300px; -} - -.sstv-live-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - background: rgba(0, 0, 0, 0.2); - border-bottom: 1px solid var(--border-color); -} - -.sstv-live-title { - display: flex; - align-items: center; - gap: 8px; - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--text-primary); -} - -.sstv-live-title svg { - color: var(--accent-cyan); -} - -.sstv-live-content { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 16px; - min-height: 0; -} - -.sstv-canvas-container { - position: relative; - background: #000; - border: 1px solid var(--border-color); - border-radius: 4px; - overflow: hidden; -} - -#sstvCanvas { - display: block; - image-rendering: pixelated; -} - -.sstv-decode-info { - width: 100%; - margin-top: 12px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.sstv-mode-label { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--accent-cyan); - text-align: center; -} - -.sstv-progress-bar { - width: 100%; - height: 4px; - background: var(--bg-secondary); - border-radius: 2px; - overflow: hidden; -} - -.sstv-progress-bar .progress { - height: 100%; - background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); - border-radius: 2px; - transition: width 0.3s ease; -} - -.sstv-status-message { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-dim); - text-align: center; -} - -/* Idle state */ -.sstv-idle-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - padding: 40px 20px; - color: var(--text-dim); -} - -.sstv-idle-state svg { - width: 64px; - height: 64px; - opacity: 0.3; - margin-bottom: 16px; -} - -.sstv-idle-state h4 { - font-size: 14px; - color: var(--text-secondary); - margin-bottom: 8px; -} - -.sstv-idle-state p { - font-size: 12px; - max-width: 250px; -} - -/* ============================================ - GALLERY SECTION - ============================================ */ -.sstv-gallery-section { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - overflow: hidden; - display: flex; - flex-direction: column; - flex: 1.5; - min-width: 300px; -} - -.sstv-gallery-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - background: rgba(0, 0, 0, 0.2); - border-bottom: 1px solid var(--border-color); -} - -.sstv-gallery-title { - display: flex; - align-items: center; - gap: 8px; - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--text-primary); -} - -.sstv-gallery-count { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--accent-cyan); - background: var(--bg-secondary); - padding: 2px 8px; - border-radius: 10px; -} - -.sstv-gallery-grid { - flex: 1; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 12px; - padding: 12px; - overflow-y: auto; - align-content: start; -} - -.sstv-image-card { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; - overflow: hidden; - transition: all 0.15s ease; - cursor: pointer; -} - -.sstv-image-card:hover { - border-color: var(--accent-cyan); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); -} - -.sstv-image-preview { - width: 100%; - aspect-ratio: 4/3; - object-fit: cover; - background: #000; - display: block; -} - -.sstv-image-info { - padding: 8px 10px; - border-top: 1px solid var(--border-color); -} - -.sstv-image-mode { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: 600; - color: var(--accent-cyan); - margin-bottom: 4px; -} - -.sstv-image-timestamp { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - color: var(--text-dim); -} - -/* Empty gallery state */ -.sstv-gallery-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 60px 20px; - text-align: center; - color: var(--text-dim); - grid-column: 1 / -1; -} - -.sstv-gallery-empty svg { - width: 48px; - height: 48px; - opacity: 0.3; - margin-bottom: 12px; -} - -/* ============================================ - TOP ROW (Map + Countdown) - ============================================ */ -.sstv-top-row { - display: flex; - gap: 12px; - height: 220px; - flex-shrink: 0; -} - -/* ============================================ - ISS MAP ROW - ============================================ */ -.sstv-map-row { - flex: 1.5; - min-width: 0; - height: 100%; -} - -.sstv-map-container { - position: relative; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - overflow: hidden; - height: 100%; -} - -.sstv-iss-map { - width: 100%; - height: 100%; - background: #0a1628; -} - -.sstv-map-overlay { - position: absolute; - bottom: 0; - left: 0; - right: 0; - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 12px; - background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); - pointer-events: none; - z-index: 1000; -} - -.sstv-map-info { - display: flex; - align-items: center; - gap: 12px; - font-family: 'JetBrains Mono', monospace; -} - -.sstv-map-label { - font-size: 10px; - font-weight: bold; - color: #ffcc00; - background: rgba(255, 204, 0, 0.2); - padding: 2px 6px; - border-radius: 3px; -} - -.sstv-map-coords { - font-size: 11px; - color: var(--accent-cyan); -} - -.sstv-map-alt { - font-size: 10px; - color: var(--text-secondary); -} - -.sstv-pass-info { - display: flex; - align-items: center; - gap: 8px; - font-family: 'JetBrains Mono', monospace; -} - -.sstv-pass-label { - font-size: 9px; - color: var(--text-dim); - text-transform: uppercase; -} - -.sstv-pass-value { - font-size: 11px; - color: var(--text-primary); -} - -/* ============================================ - ISS MAP MARKER - ============================================ */ -.sstv-iss-marker { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -.sstv-iss-dot { - width: 16px; - height: 16px; - background: #ffcc00; - border: 2px solid #fff; - border-radius: 50%; - box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4); - animation: iss-pulse 2s ease-in-out infinite; -} - -.sstv-iss-label { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - font-weight: bold; - color: #ffcc00; - text-shadow: 0 0 3px rgba(0, 0, 0, 0.8), 0 0 6px rgba(0, 0, 0, 0.5); - margin-top: 2px; -} - -@keyframes iss-pulse { - 0%, 100% { - box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4); - } - 50% { - box-shadow: 0 0 25px rgba(255, 204, 0, 1), 0 0 50px rgba(255, 204, 0, 0.6); - } -} - -/* Override Leaflet default marker styles */ -.leaflet-marker-icon.sstv-iss-marker { - background: transparent; - border: none; -} - -/* ============================================ - COUNTDOWN PANEL - ============================================ */ -.sstv-countdown-panel { - flex: 1; - min-width: 280px; - max-width: 380px; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - display: flex; - flex-direction: column; - overflow: hidden; - height: 100%; -} - -.sstv-countdown-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - background: rgba(0, 0, 0, 0.2); - border-bottom: 1px solid var(--border-color); - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--text-primary); -} - -.sstv-countdown-header svg { - color: var(--accent-cyan); -} - -.sstv-countdown-body { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 12px; - gap: 10px; -} - -.sstv-countdown-timer { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; -} - -.sstv-countdown-value { - font-family: 'JetBrains Mono', monospace; - font-size: 28px; - font-weight: 700; - color: var(--accent-cyan); - letter-spacing: 2px; - text-shadow: 0 0 20px rgba(0, 212, 255, 0.3); -} - -.sstv-countdown-value.imminent { - color: var(--accent-green); - text-shadow: 0 0 20px rgba(0, 255, 136, 0.4); - animation: countdown-pulse 1s ease-in-out infinite; -} - -.sstv-countdown-value.active { - color: var(--accent-yellow); - text-shadow: 0 0 20px rgba(255, 204, 0, 0.4); - animation: countdown-pulse 0.5s ease-in-out infinite; -} - -@keyframes countdown-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } -} - -.sstv-countdown-label { - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 1px; -} - -.sstv-countdown-details { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 6px 12px; - width: 100%; - padding: 10px; - background: var(--bg-secondary); - border-radius: 6px; -} - -.sstv-countdown-detail { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; -} - -.sstv-detail-label { - font-family: 'JetBrains Mono', monospace; - font-size: 8px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.sstv-detail-value { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 600; - color: var(--text-primary); -} - -.sstv-countdown-status { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 8px 14px; - background: rgba(0, 0, 0, 0.15); - border-top: 1px solid var(--border-color); - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - color: var(--text-dim); - text-transform: uppercase; -} - -.sstv-countdown-status .sstv-status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--text-dim); -} - -.sstv-countdown-status.has-pass .sstv-status-dot { - background: var(--accent-cyan); -} - -.sstv-countdown-status.imminent .sstv-status-dot { - background: var(--accent-green); - animation: pulse 1s infinite; -} - -.sstv-countdown-status.active .sstv-status-dot { - background: var(--accent-yellow); - box-shadow: 0 0 8px var(--accent-yellow); - animation: pulse 0.5s infinite; -} - -/* ============================================ - IMAGE MODAL - ============================================ */ -.sstv-image-modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.9); - display: none; - align-items: center; - justify-content: center; - z-index: 10000; - padding: 40px; -} - -.sstv-image-modal.show { - display: flex; -} - -.sstv-image-modal img { - max-width: 100%; - max-height: 100%; - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.sstv-modal-close { - position: absolute; - top: 20px; - right: 20px; - background: none; - border: none; - color: white; - font-size: 32px; - cursor: pointer; - opacity: 0.7; - transition: opacity 0.15s; -} - -.sstv-modal-close:hover { - opacity: 1; -} - -/* ============================================ - RESPONSIVE - ============================================ */ -@media (max-width: 1024px) { - .sstv-main-row { - flex-direction: column; - overflow-y: auto; - } - - .sstv-live-section { - max-width: none; - min-height: 350px; - } - - .sstv-gallery-section { - min-height: 300px; - } -} - -@media (max-width: 1024px) { - .sstv-top-row { - flex-direction: column; - height: auto; - } - - .sstv-map-row { - flex: none; - height: 180px; - } - - .sstv-countdown-panel { - min-width: auto; - max-width: none; - height: auto; - } - - .sstv-countdown-value { - font-size: 24px; - } - - .sstv-iss-map { - height: 180px; - } - - .sstv-map-overlay { - flex-direction: column; - align-items: flex-start; - gap: 4px; - } -} - -@media (max-width: 768px) { - .sstv-stats-strip { - padding: 8px 12px; - gap: 8px; - flex-wrap: wrap; - } - - .sstv-strip-divider { - display: none; - } - - .sstv-strip-location { - flex-wrap: wrap; - } - - .sstv-loc-input { - width: 55px; - } - - .sstv-gallery-grid { - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 8px; - padding: 8px; - } - - .sstv-iss-map { - height: 150px; - } - - .sstv-map-info { - gap: 8px; - } - - .sstv-map-overlay { - padding: 6px 10px; - } -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} +/** + * SSTV Mode Styles + * ISS Slow-Scan Television decoder interface + */ + +/* ============================================ + MODE VISIBILITY + ============================================ */ +#sstvMode.active { + display: block !important; +} + +/* ============================================ + VISUALS CONTAINER + ============================================ */ +.sstv-visuals-container { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + min-height: 0; + flex: 1; + height: 100%; + overflow: hidden; +} + +/* ============================================ + MAIN ROW (Live Decode + Gallery) + ============================================ */ +.sstv-main-row { + display: flex; + flex-direction: row; + gap: 12px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ============================================ + STATS STRIP + ============================================ */ +.sstv-stats-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.sstv-strip-group { + display: flex; + align-items: center; + gap: 12px; +} + +.sstv-strip-status { + display: flex; + align-items: center; + gap: 6px; +} + +.sstv-strip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.sstv-strip-dot.idle { + background: var(--text-dim); +} + +.sstv-strip-dot.listening { + background: var(--accent-yellow); + animation: pulse 1s infinite; +} + +.sstv-strip-dot.decoding { + background: var(--accent-cyan); + box-shadow: 0 0 6px var(--accent-cyan); + animation: pulse 0.5s infinite; +} + +.sstv-strip-status-text { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + text-transform: uppercase; +} + +.sstv-strip-btn { + font-family: var(--font-mono); + font-size: 10px; + padding: 5px 12px; + border: none; + border-radius: 4px; + cursor: pointer; + text-transform: uppercase; + font-weight: 600; + transition: all 0.15s ease; +} + +.sstv-strip-btn.start { + background: var(--accent-cyan); + color: var(--bg-primary); +} + +.sstv-strip-btn.start:hover { + background: var(--accent-cyan-bright, #00d4ff); +} + +.sstv-strip-btn.stop { + background: var(--accent-red, #ff3366); + color: white; +} + +.sstv-strip-btn.stop:hover { + background: #ff1a53; +} + +.sstv-strip-divider { + width: 1px; + height: 24px; + background: var(--border-color); +} + +.sstv-strip-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 50px; +} + +.sstv-strip-value { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-strip-value.accent-cyan { + color: var(--accent-cyan); +} + +.sstv-strip-label { + font-family: var(--font-mono); + font-size: 8px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Location inputs in strip */ +.sstv-strip-location { + display: flex; + align-items: center; + gap: 4px; +} + +.sstv-loc-input { + width: 70px; + padding: 4px 6px; + font-family: var(--font-mono); + font-size: 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + text-align: right; +} + +.sstv-loc-input:focus { + outline: none; + border-color: var(--accent-cyan); +} + +.sstv-strip-btn.gps { + display: flex; + align-items: center; + gap: 4px; + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.sstv-strip-btn.gps:hover { + background: var(--accent-green); + color: #000; + border-color: var(--accent-green); +} + +.sstv-strip-btn.update-tle { + display: flex; + align-items: center; + gap: 4px; + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.sstv-strip-btn.update-tle:hover { + background: var(--accent-orange); + color: #000; + border-color: var(--accent-orange); +} + +/* ============================================ + LIVE DECODE SECTION + ============================================ */ +.sstv-live-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-width: 300px; +} + +.sstv-live-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); +} + +.sstv-live-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-live-title svg { + color: var(--accent-cyan); +} + +.sstv-live-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px; + min-height: 0; +} + +.sstv-canvas-container { + position: relative; + background: #000; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +#sstvCanvas { + display: block; + image-rendering: pixelated; +} + +.sstv-decode-info { + width: 100%; + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sstv-mode-label { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent-cyan); + text-align: center; +} + +.sstv-progress-bar { + width: 100%; + height: 4px; + background: var(--bg-secondary); + border-radius: 2px; + overflow: hidden; +} + +.sstv-progress-bar .progress { + height: 100%; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); + border-radius: 2px; + transition: width 0.3s ease; +} + +.sstv-status-message { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-align: center; +} + +/* Idle state */ +.sstv-idle-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 40px 20px; + color: var(--text-dim); +} + +.sstv-idle-state svg { + width: 64px; + height: 64px; + opacity: 0.3; + margin-bottom: 16px; +} + +.sstv-idle-state h4 { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.sstv-idle-state p { + font-size: 12px; + max-width: 250px; +} + +/* ============================================ + GALLERY SECTION + ============================================ */ +.sstv-gallery-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1.5; + min-width: 300px; +} + +.sstv-gallery-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); +} + +.sstv-gallery-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-gallery-count { + font-family: var(--font-mono); + font-size: 10px; + color: var(--accent-cyan); + background: var(--bg-secondary); + padding: 2px 8px; + border-radius: 10px; +} + +.sstv-gallery-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + padding: 12px; + overflow-y: auto; + align-content: start; +} + +.sstv-image-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + transition: all 0.15s ease; + cursor: pointer; +} + +.sstv-image-card:hover { + border-color: var(--accent-cyan); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); +} + +.sstv-image-preview { + width: 100%; + aspect-ratio: 4/3; + object-fit: cover; + background: #000; + display: block; +} + +.sstv-image-info { + padding: 8px 10px; + border-top: 1px solid var(--border-color); +} + +.sstv-image-mode { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--accent-cyan); + margin-bottom: 4px; +} + +.sstv-image-timestamp { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); +} + +/* Empty gallery state */ +.sstv-gallery-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + color: var(--text-dim); + grid-column: 1 / -1; +} + +.sstv-gallery-empty svg { + width: 48px; + height: 48px; + opacity: 0.3; + margin-bottom: 12px; +} + +/* ============================================ + TOP ROW (Map + Countdown) + ============================================ */ +.sstv-top-row { + display: flex; + gap: 12px; + height: 220px; + flex-shrink: 0; +} + +/* ============================================ + ISS MAP ROW + ============================================ */ +.sstv-map-row { + flex: 1.5; + min-width: 0; + height: 100%; +} + +.sstv-map-container { + position: relative; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + height: 100%; +} + +.sstv-iss-map { + width: 100%; + height: 100%; + background: #0a1628; +} + +.sstv-map-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + pointer-events: none; + z-index: 1000; +} + +.sstv-map-info { + display: flex; + align-items: center; + gap: 12px; + font-family: var(--font-mono); +} + +.sstv-map-label { + font-size: 10px; + font-weight: bold; + color: #ffcc00; + background: rgba(255, 204, 0, 0.2); + padding: 2px 6px; + border-radius: 3px; +} + +.sstv-map-coords { + font-size: 11px; + color: var(--accent-cyan); +} + +.sstv-map-alt { + font-size: 10px; + color: var(--text-secondary); +} + +.sstv-pass-info { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); +} + +.sstv-pass-label { + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; +} + +.sstv-pass-value { + font-size: 11px; + color: var(--text-primary); +} + +/* ============================================ + ISS MAP MARKER + ============================================ */ +.sstv-iss-marker { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.sstv-iss-dot { + width: 16px; + height: 16px; + background: #ffcc00; + border: 2px solid #fff; + border-radius: 50%; + box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4); + animation: iss-pulse 2s ease-in-out infinite; +} + +.sstv-iss-label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: bold; + color: #ffcc00; + text-shadow: 0 0 3px rgba(0, 0, 0, 0.8), 0 0 6px rgba(0, 0, 0, 0.5); + margin-top: 2px; +} + +@keyframes iss-pulse { + 0%, 100% { + box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4); + } + 50% { + box-shadow: 0 0 25px rgba(255, 204, 0, 1), 0 0 50px rgba(255, 204, 0, 0.6); + } +} + +/* Override Leaflet default marker styles */ +.leaflet-marker-icon.sstv-iss-marker { + background: transparent; + border: none; +} + +/* ============================================ + COUNTDOWN PANEL + ============================================ */ +.sstv-countdown-panel { + flex: 1; + min-width: 280px; + max-width: 380px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; +} + +.sstv-countdown-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-countdown-header svg { + color: var(--accent-cyan); +} + +.sstv-countdown-body { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 12px; + gap: 10px; +} + +.sstv-countdown-timer { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.sstv-countdown-value { + font-family: var(--font-mono); + font-size: 28px; + font-weight: 700; + color: var(--accent-cyan); + letter-spacing: 2px; + text-shadow: 0 0 20px rgba(0, 212, 255, 0.3); +} + +.sstv-countdown-value.imminent { + color: var(--accent-green); + text-shadow: 0 0 20px rgba(0, 255, 136, 0.4); + animation: countdown-pulse 1s ease-in-out infinite; +} + +.sstv-countdown-value.active { + color: var(--accent-yellow); + text-shadow: 0 0 20px rgba(255, 204, 0, 0.4); + animation: countdown-pulse 0.5s ease-in-out infinite; +} + +@keyframes countdown-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.sstv-countdown-label { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1px; +} + +.sstv-countdown-details { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px 12px; + width: 100%; + padding: 10px; + background: var(--bg-secondary); + border-radius: 6px; +} + +.sstv-countdown-detail { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.sstv-detail-label { + font-family: var(--font-mono); + font-size: 8px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sstv-detail-value { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-countdown-status { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 14px; + background: rgba(0, 0, 0, 0.15); + border-top: 1px solid var(--border-color); + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; +} + +.sstv-countdown-status .sstv-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-dim); +} + +.sstv-countdown-status.has-pass .sstv-status-dot { + background: var(--accent-cyan); +} + +.sstv-countdown-status.imminent .sstv-status-dot { + background: var(--accent-green); + animation: pulse 1s infinite; +} + +.sstv-countdown-status.active .sstv-status-dot { + background: var(--accent-yellow); + box-shadow: 0 0 8px var(--accent-yellow); + animation: pulse 0.5s infinite; +} + +/* ============================================ + IMAGE MODAL + ============================================ */ +.sstv-image-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: none; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 40px; +} + +.sstv-image-modal.show { + display: flex; +} + +.sstv-image-modal img { + max-width: 100%; + max-height: 100%; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.sstv-modal-close { + position: absolute; + top: 20px; + right: 20px; + background: none; + border: none; + color: white; + font-size: 32px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s; +} + +.sstv-modal-close:hover { + opacity: 1; +} + +/* ============================================ + RESPONSIVE + ============================================ */ +@media (max-width: 1024px) { + .sstv-main-row { + flex-direction: column; + overflow-y: auto; + } + + .sstv-live-section { + max-width: none; + min-height: 350px; + } + + .sstv-gallery-section { + min-height: 300px; + } +} + +@media (max-width: 1024px) { + .sstv-top-row { + flex-direction: column; + height: auto; + } + + .sstv-map-row { + flex: none; + height: 180px; + } + + .sstv-countdown-panel { + min-width: auto; + max-width: none; + height: auto; + } + + .sstv-countdown-value { + font-size: 24px; + } + + .sstv-iss-map { + height: 180px; + } + + .sstv-map-overlay { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } +} + +@media (max-width: 768px) { + .sstv-stats-strip { + padding: 8px 12px; + gap: 8px; + flex-wrap: wrap; + } + + .sstv-strip-divider { + display: none; + } + + .sstv-strip-location { + flex-wrap: wrap; + } + + .sstv-loc-input { + width: 55px; + } + + .sstv-gallery-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px; + padding: 8px; + } + + .sstv-iss-map { + height: 150px; + } + + .sstv-map-info { + gap: 8px; + } + + .sstv-map-overlay { + padding: 6px 10px; + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} diff --git a/static/css/modes/tscm.css b/static/css/modes/tscm.css index 981e103..63a43a5 100644 --- a/static/css/modes/tscm.css +++ b/static/css/modes/tscm.css @@ -1,1463 +1,1463 @@ -/* TSCM Styles */ - -/* TSCM Threat Cards */ -.threat-card { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 8px; - border-radius: 6px; - background: rgba(0,0,0,0.3); - border: 1px solid var(--border-color); -} -.threat-card .count { - font-size: 18px; - font-weight: bold; - line-height: 1; -} -.threat-card .label { - font-size: 9px; - text-transform: uppercase; - opacity: 0.7; - margin-top: 2px; -} -.threat-card.critical { border-color: #ff3366; color: #ff3366; } -.threat-card.critical.active { background: rgba(255,51,102,0.2); } -.threat-card.high { border-color: #ff9933; color: #ff9933; } -.threat-card.high.active { background: rgba(255,153,51,0.2); } -.threat-card.medium { border-color: #ffcc00; color: #ffcc00; } -.threat-card.medium.active { background: rgba(255,204,0,0.2); } -.threat-card.low { border-color: #00ff88; color: #00ff88; } -.threat-card.low.active { background: rgba(0,255,136,0.2); } - -/* TSCM Dashboard */ -.tscm-dashboard { - display: flex; - flex-direction: column; - gap: 16px; - overflow-y: auto; - padding-bottom: 80px; /* Space for status bar */ -} -.tscm-threat-banner { - display: flex; - gap: 12px; - padding: 12px; - background: rgba(0,0,0,0.3); - border-radius: 8px; -} -.tscm-threat-banner .threat-card { - flex: 1; - padding: 12px; -} -.tscm-threat-banner .threat-card .count { - font-size: 24px; -} -.tscm-main-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; -} -.tscm-panel { - background: rgba(0,0,0,0.3); - border: 1px solid var(--border-color); - border-radius: 8px; - display: flex; - flex-direction: column; - overflow: hidden; - min-height: 200px; - height: 200px; -} -/* Full-width panels (like Detected Threats) get more height */ -.tscm-panel[style*="grid-column: span 2"] { - min-height: 150px; - height: 150px; -} -.tscm-panel-header { - padding: 10px 12px; - background: rgba(0,0,0,0.3); - border-bottom: 1px solid var(--border-color); - font-weight: 600; - font-size: 12px; - display: flex; - justify-content: space-between; - align-items: center; -} -.tscm-panel-header .badge { - background: var(--primary-color); - color: #fff; - padding: 2px 8px; - border-radius: 10px; - font-size: 10px; - font-weight: normal; -} -.tscm-panel-content { - flex: 1; - overflow-y: auto; - padding: 8px; -} -.tscm-device-item { - padding: 8px 10px; - border-radius: 4px; - margin-bottom: 6px; - background: rgba(0,0,0,0.2); - border-left: 3px solid var(--border-color); - cursor: pointer; - transition: background 0.2s; -} -.tscm-device-item:hover { - background: rgba(74,158,255,0.1); -} -.tscm-device-item.new { - border-left-color: #ff9933; - animation: pulse-glow 2s infinite; -} -.tscm-device-item.threat { - border-left-color: #ff3366; -} -.tscm-device-item.baseline { - border-left-color: #00ff88; -} -/* Classification colors */ -.tscm-device-item.classification-green { - border-left-color: #00cc00; - background: rgba(0, 204, 0, 0.1); -} -.tscm-device-item.classification-yellow { - border-left-color: #ffcc00; - background: rgba(255, 204, 0, 0.1); -} -.tscm-device-item.classification-red { - border-left-color: #ff3333; - background: rgba(255, 51, 51, 0.15); - animation: pulse-glow 2s infinite; -} -.classification-indicator { - margin-right: 6px; -} -.tscm-status-message { - padding: 12px; - background: rgba(255, 153, 51, 0.15); - border: 1px solid rgba(255, 153, 51, 0.3); - border-radius: 6px; - color: var(--text-primary); - font-size: 12px; - display: flex; - align-items: center; - gap: 8px; -} -.tscm-status-message .status-icon { - font-size: 16px; -} -.tscm-privilege-warning { - padding: 10px 12px; - background: rgba(255, 51, 51, 0.15); - border: 1px solid rgba(255, 51, 51, 0.4); - border-radius: 6px; - color: var(--text-primary); - font-size: 11px; - display: flex; - align-items: flex-start; - gap: 10px; - margin-bottom: 12px; -} -.tscm-privilege-warning .warning-icon { - font-size: 18px; - flex-shrink: 0; -} -.tscm-privilege-warning .warning-action { - margin-top: 4px; - font-family: 'JetBrains Mono', monospace; - font-size: 10px; - color: var(--accent-cyan); - background: rgba(0, 0, 0, 0.3); - padding: 4px 8px; - border-radius: 3px; -} -.tscm-action-btn { - padding: 10px 16px; - background: var(--accent-green); - border: none; - border-radius: 4px; - color: #000; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; -} -.tscm-action-btn:hover { - background: #2ecc71; - transform: translateY(-1px); -} -.tscm-device-reasons { - font-size: 10px; - color: var(--text-secondary); - margin-top: 4px; - font-style: italic; - line-height: 1.4; -} -.audio-badge { - margin-left: 6px; - font-size: 10px; -} -.tscm-device-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; -} -.tscm-device-name { - font-weight: 600; - font-size: 12px; -} -.tscm-device-meta { - font-size: 10px; - color: var(--text-muted); - display: flex; - gap: 8px; - flex-wrap: wrap; -} -.tscm-device-indicators { - margin-top: 6px; - display: flex; - flex-wrap: wrap; - gap: 4px; -} -.indicator-tag { - font-size: 9px; - padding: 2px 6px; - border-radius: 3px; - background: rgba(255, 255, 255, 0.1); - color: var(--text-secondary); - white-space: nowrap; -} -.score-badge { - font-size: 10px; - padding: 2px 8px; - border-radius: 10px; - font-weight: 600; -} -.score-badge.score-low { - background: rgba(0, 204, 0, 0.2); - color: #00cc00; -} -.score-badge.score-medium { - background: rgba(255, 204, 0, 0.2); - color: #ffcc00; -} -.score-badge.score-high { - background: rgba(255, 51, 51, 0.2); - color: #ff3333; -} -.tscm-action { - margin-top: 4px; - font-size: 10px; - color: #ff9933; - font-weight: 600; - text-transform: uppercase; -} -.tscm-correlations { - margin-top: 16px; - padding: 12px; - background: rgba(255, 153, 51, 0.1); - border-radius: 6px; - border: 1px solid #ff9933; -} -.tscm-correlations h4 { - margin: 0 0 8px 0; - font-size: 12px; - color: #ff9933; -} -.correlation-item { - padding: 8px; - margin-bottom: 6px; - background: rgba(0, 0, 0, 0.2); - border-radius: 4px; - font-size: 11px; -} -.correlation-devices { - font-size: 10px; - color: var(--text-muted); - margin-top: 4px; -} -.tscm-summary-box { - display: flex; - gap: 12px; - margin-bottom: 16px; - flex-wrap: wrap; -} -.summary-stat { - flex: 1; - min-width: 100px; - padding: 12px; - background: rgba(0, 0, 0, 0.3); - border-radius: 6px; - text-align: center; -} -.summary-stat .count { - font-size: 24px; - font-weight: 700; -} -.summary-stat .label { - font-size: 10px; - color: var(--text-muted); - text-transform: uppercase; -} -.summary-stat.high-interest .count { color: #ff3333; } -.summary-stat.needs-review .count { color: #ffcc00; } -.summary-stat.informational .count { color: #00cc00; } -.tscm-assessment { - padding: 10px 14px; - margin: 12px 0; - border-radius: 6px; - font-size: 13px; -} -.tscm-assessment.high-interest { - background: rgba(255, 51, 51, 0.15); - border: 1px solid #ff3333; - color: #ff3333; -} -.tscm-assessment.needs-review { - background: rgba(255, 204, 0, 0.15); - border: 1px solid #ffcc00; - color: #ffcc00; -} -.tscm-assessment.informational { - background: rgba(0, 204, 0, 0.15); - border: 1px solid #00cc00; - color: #00cc00; -} -.tscm-disclaimer { - font-size: 10px; - color: var(--text-muted); - font-style: italic; - padding: 8px 12px; - background: rgba(0, 0, 0, 0.2); - border-radius: 4px; - margin-top: 8px; -} - -/* TSCM Device Details Modal */ -.tscm-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; -} -.tscm-modal { - background: var(--bg-card); - border: 1px solid var(--border-light); - border-radius: 8px; - max-width: 500px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - position: relative; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); -} -.tscm-modal-close { - position: absolute; - top: 12px; - right: 12px; - background: var(--bg-tertiary); - border: 1px solid var(--border-light); - border-radius: 50%; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); - font-size: 20px; - cursor: pointer; - z-index: 10; - transition: all 0.2s; -} -.tscm-modal-close:hover { - background: var(--accent-red); - border-color: var(--accent-red); - color: #fff; -} -.device-detail-header { - padding: 16px; - padding-right: 52px; /* Reserve space for close button */ - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; -} -.device-detail-header h3 { - margin: 0; - font-size: 16px; -} -.device-detail-header.classification-red { background: rgba(255, 51, 51, 0.15); } -.device-detail-header.classification-yellow { background: rgba(255, 204, 0, 0.15); } -.device-detail-header.classification-green { background: rgba(0, 204, 0, 0.15); } -.device-detail-protocol { - font-size: 10px; - padding: 3px 8px; - background: rgba(255, 255, 255, 0.1); - border-radius: 3px; - text-transform: uppercase; -} -.device-detail-score { - display: flex; - align-items: center; - padding: 16px; - gap: 16px; - border-bottom: 1px solid var(--border-color); -} -.score-circle { - width: 70px; - height: 70px; - border-radius: 50%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - border: 3px solid; -} -.score-circle.high { border-color: #ff3333; background: rgba(255, 51, 51, 0.1); } -.score-circle.medium { border-color: #ffcc00; background: rgba(255, 204, 0, 0.1); } -.score-circle.low { border-color: #00cc00; background: rgba(0, 204, 0, 0.1); } -.score-circle .score-value { - font-size: 24px; - font-weight: 700; -} -.score-circle.high .score-value { color: #ff3333; } -.score-circle.medium .score-value { color: #ffcc00; } -.score-circle.low .score-value { color: #00cc00; } -.score-circle .score-label { - font-size: 8px; - color: var(--text-muted); - text-transform: uppercase; -} -.score-breakdown { - flex: 1; - font-size: 12px; - line-height: 1.6; -} -.device-detail-section { - padding: 16px; - border-bottom: 1px solid var(--border-color); -} -.device-detail-section h4 { - margin: 0 0 12px 0; - font-size: 12px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; -} -.device-detail-table { - width: 100%; - font-size: 12px; -} -.device-detail-table td { - padding: 4px 0; -} -.device-detail-table td:first-child { - color: var(--text-dim); - width: 40%; -} -.indicator-list { - display: flex; - flex-direction: column; - gap: 8px; -} -.indicator-item { - display: flex; - gap: 10px; - padding: 8px; - background: rgba(0, 0, 0, 0.2); - border-radius: 4px; - font-size: 11px; -} -.indicator-type { - background: rgba(255, 153, 51, 0.2); - color: #ff9933; - padding: 2px 6px; - border-radius: 3px; - font-size: 10px; - white-space: nowrap; -} -.indicator-desc { - color: var(--text-color); -} -.device-reasons-list { - margin: 0; - padding-left: 20px; - font-size: 12px; - color: var(--text-primary); -} -.device-reasons-list li { - margin-bottom: 4px; - color: var(--text-secondary); -} -.device-detail-disclaimer { - padding: 12px 16px; - font-size: 10px; - color: var(--text-secondary); - background: rgba(74, 158, 255, 0.1); - border-top: 1px solid rgba(74, 158, 255, 0.3); -} -.tscm-threat-action { - margin-top: 6px; - font-size: 10px; - color: #ff9933; - text-transform: uppercase; - font-weight: 600; -} -.tscm-device-item { - cursor: pointer; -} -.tscm-device-item:hover { - background: rgba(255, 255, 255, 0.05); -} -.threat-card.clickable { - cursor: pointer; - transition: transform 0.2s, box-shadow 0.2s; -} -.threat-card.clickable:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} -.category-device-list { - max-height: 400px; - overflow-y: auto; -} -.category-device-item { - padding: 12px 16px; - border-bottom: 1px solid var(--border-color); - cursor: pointer; - transition: background 0.2s; -} -.category-device-item:hover { - background: rgba(255, 255, 255, 0.05); -} -.category-device-header { - display: flex; - justify-content: space-between; - align-items: center; -} -.category-device-name { - font-weight: 600; - font-size: 13px; -} -.category-device-score { - background: rgba(255, 255, 255, 0.1); - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; -} -.category-device-meta { - display: flex; - gap: 6px; - margin-top: 6px; -} -.protocol-badge { - font-size: 9px; - padding: 2px 6px; - background: rgba(74, 158, 255, 0.2); - color: #4a9eff; - border-radius: 3px; - text-transform: uppercase; -} -.indicator-mini { - font-size: 9px; - padding: 2px 6px; - background: rgba(255, 153, 51, 0.2); - color: #ff9933; - border-radius: 3px; -} -.correlation-detail-item { - padding: 12px; - background: rgba(0, 0, 0, 0.2); - border-radius: 6px; - margin-bottom: 8px; -} -.tscm-threat-list { - display: flex; - flex-direction: column; - gap: 8px; -} -.tscm-threat-item { - padding: 10px 12px; - border-radius: 6px; - background: rgba(0,0,0,0.2); - border: 1px solid; -} -.tscm-threat-item.critical { border-color: #ff3366; background: rgba(255,51,102,0.1); } -.tscm-threat-item.high { border-color: #ff9933; background: rgba(255,153,51,0.1); } -.tscm-threat-item.medium { border-color: #ffcc00; background: rgba(255,204,0,0.1); } -.tscm-threat-item.low { border-color: #00ff88; background: rgba(0,255,136,0.1); } -.tscm-threat-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; -} -.tscm-threat-type { - font-weight: 600; - font-size: 12px; -} -.tscm-threat-severity { - font-size: 10px; - padding: 2px 6px; - border-radius: 3px; - text-transform: uppercase; -} -.tscm-threat-item.critical .tscm-threat-severity { background: #ff3366; color: #fff; } -.tscm-threat-item.high .tscm-threat-severity { background: #ff9933; color: #000; } -.tscm-threat-item.medium .tscm-threat-severity { background: #ffcc00; color: #000; } -.tscm-threat-item.low .tscm-threat-severity { background: #00ff88; color: #000; } -.tscm-threat-details { - font-size: 11px; - color: var(--text-muted); -} -@keyframes pulse-glow { - 0%, 100% { box-shadow: 0 0 5px rgba(255,153,51,0.3); } - 50% { box-shadow: 0 0 15px rgba(255,153,51,0.6); } -} -.tscm-empty { - text-align: center; - padding: 30px; - color: var(--text-muted); - font-size: 12px; -} -.tscm-empty-primary { - font-weight: 500; - color: var(--text-secondary); - margin-bottom: 6px; -} -.tscm-empty-secondary { - font-size: 10px; - color: var(--text-muted); - max-width: 280px; - margin: 0 auto; - line-height: 1.4; -} - -/* Futuristic Scanner Progress */ -.tscm-scanner-progress { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - margin-top: 10px; - background: rgba(0,0,0,0.4); - border: 1px solid var(--border-color); - border-radius: 8px; -} -.scanner-ring { - position: relative; - width: 70px; - height: 70px; - flex-shrink: 0; -} -.scanner-ring svg { - width: 100%; - height: 100%; - transform: rotate(-90deg); -} -.scanner-track { - fill: none; - stroke: rgba(74,158,255,0.1); - stroke-width: 4; -} -.scanner-progress { - fill: none; - stroke: var(--accent-cyan); - stroke-width: 4; - stroke-linecap: round; - stroke-dasharray: 283; - stroke-dashoffset: 283; - transition: stroke-dashoffset 0.3s ease; - filter: drop-shadow(0 0 6px var(--accent-cyan)); -} -.scanner-sweep { - stroke: var(--accent-cyan); - stroke-width: 2; - opacity: 0.8; - transform-origin: 50px 50px; - animation: sweep-rotate 2s linear infinite; - filter: drop-shadow(0 0 4px var(--accent-cyan)); -} -@keyframes sweep-rotate { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} -.scanner-center { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - text-align: center; -} -.scanner-percent { - font-size: 14px; - font-weight: bold; - color: var(--accent-cyan); - text-shadow: 0 0 10px var(--accent-cyan); -} -.scanner-info { - flex: 1; - min-width: 0; -} -.scanner-status { - font-size: 10px; - font-weight: 600; - letter-spacing: 2px; - color: var(--accent-cyan); - margin-bottom: 6px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.scanner-devices { - display: flex; - gap: 8px; -} -.device-indicator { - font-size: 14px; - opacity: 0.3; - transition: opacity 0.3s, transform 0.3s; -} -.device-indicator.active { - opacity: 1; - animation: device-pulse 1.5s ease-in-out infinite; -} -.device-indicator.inactive { - opacity: 0.2; - filter: grayscale(1); -} -@keyframes device-pulse { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.1); } -} - -/* Meeting Window Banner */ -.tscm-meeting-banner { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - margin-bottom: 12px; - background: linear-gradient(90deg, rgba(255, 51, 102, 0.2), rgba(255, 153, 51, 0.2)); - border: 1px solid rgba(255, 51, 102, 0.5); - border-radius: 6px; - animation: meeting-glow 2s ease-in-out infinite; -} -@keyframes meeting-glow { - 0%, 100% { box-shadow: 0 0 5px rgba(255, 51, 102, 0.3); } - 50% { box-shadow: 0 0 15px rgba(255, 51, 102, 0.5); } -} -.meeting-indicator { - display: flex; - align-items: center; - gap: 8px; -} -.meeting-pulse { - width: 10px; - height: 10px; - background: #ff3366; - border-radius: 50%; - animation: pulse-dot 1.5s ease-in-out infinite; -} -@keyframes pulse-dot { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(1.2); } -} -.meeting-text { - font-size: 11px; - font-weight: 700; - letter-spacing: 1px; - color: #ff3366; - text-transform: uppercase; -} -.meeting-info { - font-size: 11px; - color: var(--text-secondary); - display: flex; - gap: 12px; -} - -/* Capabilities Bar */ -.tscm-capabilities-bar { - display: flex; - align-items: center; - gap: 16px; - padding: 8px 12px; - margin-bottom: 12px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--border-color); - border-radius: 6px; - font-size: 11px; -} -.cap-item { - display: flex; - align-items: center; - gap: 4px; -} -.cap-icon { - font-size: 14px; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; -} -.cap-icon svg { - width: 100%; - height: 100%; - stroke: currentColor; - fill: none; -} -.cap-status { - color: var(--text-muted); - font-size: 10px; - text-transform: uppercase; -} -.cap-status.available { color: #00cc00; } -.cap-status.limited { color: #ffcc00; } -.cap-status.unavailable { color: #ff3333; } -.cap-limitations { - margin-left: auto; - display: flex; - align-items: center; - gap: 4px; - color: #ff9933; - font-size: 10px; -} -.cap-warn { - font-size: 12px; -} - -/* Baseline Health Indicator */ -.tscm-baseline-health { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - margin-bottom: 12px; - background: rgba(0, 0, 0, 0.2); - border-radius: 4px; - font-size: 11px; -} -.health-label { - color: var(--text-muted); -} -.health-name { - color: var(--text-primary); - font-weight: 600; -} -.health-badge { - padding: 2px 8px; - border-radius: 10px; - font-size: 9px; - font-weight: 600; - text-transform: uppercase; -} -.health-badge.healthy { - background: rgba(0, 204, 0, 0.2); - color: #00cc00; -} -.health-badge.noisy { - background: rgba(255, 204, 0, 0.2); - color: #ffcc00; -} -.health-badge.stale { - background: rgba(255, 51, 51, 0.2); - color: #ff3333; -} -.health-age { - color: var(--text-muted); - font-size: 10px; - margin-left: auto; -} - -/* Advanced Modal Styles */ -.tscm-advanced-modal { - max-width: 600px; -} -.tscm-modal-header { - padding: 16px; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; -} -.tscm-modal-header h3 { - margin: 0; - font-size: 16px; -} -.tscm-modal-body { - padding: 16px; - max-height: 60vh; - overflow-y: auto; -} -.tscm-modal-section { - margin-bottom: 16px; -} -.tscm-modal-section h4 { - margin: 0 0 8px 0; - font-size: 12px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* Capabilities Detail */ -.cap-detail-item { - padding: 10px; - margin-bottom: 8px; - background: rgba(0, 0, 0, 0.2); - border-radius: 4px; - border-left: 3px solid var(--border-color); -} -.cap-detail-item.available { border-left-color: #00cc00; } -.cap-detail-item.limited { border-left-color: #ffcc00; } -.cap-detail-item.unavailable { border-left-color: #ff3333; } -.cap-detail-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; -} -.cap-detail-name { - font-weight: 600; - font-size: 12px; -} -.cap-detail-status { - font-size: 10px; - padding: 2px 6px; - border-radius: 3px; -} -.cap-detail-status.available { background: rgba(0, 204, 0, 0.2); color: #00cc00; } -.cap-detail-status.limited { background: rgba(255, 204, 0, 0.2); color: #ffcc00; } -.cap-detail-status.unavailable { background: rgba(255, 51, 51, 0.2); color: #ff3333; } -.cap-detail-limits { - font-size: 10px; - color: var(--text-muted); - margin-top: 4px; -} -.cap-detail-limits li { - margin-bottom: 2px; -} - -/* Known Devices List */ -.known-device-item { - padding: 10px; - margin-bottom: 6px; - background: rgba(0, 0, 0, 0.2); - border-radius: 4px; - border-left: 3px solid #00cc00; - display: flex; - justify-content: space-between; - align-items: center; -} -.known-device-info { - flex: 1; -} -.known-device-name { - font-weight: 600; - font-size: 12px; -} -.known-device-id { - font-size: 10px; - color: var(--text-muted); - font-family: 'JetBrains Mono', monospace; -} -.known-device-actions { - display: flex; - gap: 6px; -} -.known-device-btn { - padding: 4px 8px; - font-size: 10px; - border: none; - border-radius: 3px; - cursor: pointer; -} -.known-device-btn.remove { - background: rgba(255, 51, 51, 0.2); - color: #ff3333; -} -.known-device-btn.remove:hover { - background: rgba(255, 51, 51, 0.4); -} - -/* Cases List */ -.case-item { - padding: 12px; - margin-bottom: 8px; - background: rgba(0, 0, 0, 0.2); - border-radius: 6px; - border-left: 3px solid var(--primary-color); - cursor: pointer; - transition: background 0.2s; -} -.case-item:hover { - background: rgba(74, 158, 255, 0.1); -} -.case-item.priority-high { border-left-color: #ff3333; } -.case-item.priority-normal { border-left-color: #4a9eff; } -.case-item.priority-low { border-left-color: #00cc00; } -.case-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; -} -.case-name { - font-weight: 600; - font-size: 13px; -} -.case-status { - font-size: 9px; - padding: 2px 6px; - border-radius: 3px; - text-transform: uppercase; -} -.case-status.open { background: rgba(0, 204, 0, 0.2); color: #00cc00; } -.case-status.closed { background: rgba(128, 128, 128, 0.2); color: #888; } -.case-meta { - font-size: 10px; - color: var(--text-muted); - display: flex; - gap: 12px; -} - -/* Playbook Styles */ -.playbook-item { - padding: 12px; - margin-bottom: 8px; - background: rgba(0, 0, 0, 0.2); - border-radius: 6px; - border-left: 3px solid #ff9933; -} -.playbook-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} -.playbook-title { - font-weight: 600; - font-size: 13px; -} -.playbook-risk { - font-size: 9px; - padding: 2px 6px; - border-radius: 3px; - text-transform: uppercase; -} -.playbook-risk.high_interest { background: rgba(255, 51, 51, 0.2); color: #ff3333; } -.playbook-risk.needs_review { background: rgba(255, 204, 0, 0.2); color: #ffcc00; } -.playbook-risk.informational { background: rgba(0, 204, 0, 0.2); color: #00cc00; } -.playbook-desc { - font-size: 11px; - color: var(--text-secondary); - margin-bottom: 8px; -} -.playbook-steps { - font-size: 11px; -} -.playbook-step { - padding: 6px 8px; - margin-bottom: 4px; - background: rgba(0, 0, 0, 0.2); - border-radius: 3px; -} -.playbook-step-num { - color: #ff9933; - font-weight: 600; - margin-right: 6px; -} - -/* Timeline Styles */ -.timeline-container { - padding: 12px; - background: rgba(0, 0, 0, 0.2); - border-radius: 6px; -} -.timeline-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -} -.timeline-device-name { - font-weight: 600; - font-size: 14px; -} -.timeline-metrics { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; - margin-bottom: 12px; -} -.timeline-metric { - padding: 8px; - background: rgba(0, 0, 0, 0.3); - border-radius: 4px; - text-align: center; -} -.timeline-metric-value { - font-size: 16px; - font-weight: 700; - color: var(--accent-cyan); -} -.timeline-metric-label { - font-size: 9px; - color: var(--text-muted); - text-transform: uppercase; -} -.timeline-chart { - height: 60px; - background: rgba(0, 0, 0, 0.3); - border-radius: 4px; - position: relative; - overflow: hidden; -} -.timeline-bar { - position: absolute; - bottom: 0; - width: 3px; - background: var(--accent-cyan); - border-radius: 2px 2px 0 0; -} - -/* Proximity Badge */ -.proximity-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 10px; - font-size: 10px; - font-weight: 600; -} -.proximity-badge.very_close { - background: rgba(255, 51, 51, 0.2); - color: #ff3333; -} -.proximity-badge.close { - background: rgba(255, 153, 51, 0.2); - color: #ff9933; -} -.proximity-badge.moderate { - background: rgba(255, 204, 0, 0.2); - color: #ffcc00; -} -.proximity-badge.far { - background: rgba(0, 204, 0, 0.2); - color: #00cc00; -} - -/* Add to Known Device Button */ -.add-known-btn { - padding: 4px 8px; - font-size: 10px; - background: rgba(0, 204, 0, 0.2); - color: #00cc00; - border: 1px solid rgba(0, 204, 0, 0.3); - border-radius: 3px; - cursor: pointer; - transition: all 0.2s; -} -.add-known-btn:hover { - background: rgba(0, 204, 0, 0.3); -} - -/* Capabilities Grid */ -.capabilities-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; - margin-bottom: 16px; -} -.cap-detail-item .cap-icon { - font-size: 24px; - display: block; - margin-bottom: 8px; - width: 24px; - height: 24px; -} -.cap-detail-item .cap-icon svg { - width: 100%; - height: 100%; - stroke: currentColor; - fill: none; -} -.cap-detail-item .cap-name { - font-weight: 600; - font-size: 12px; - display: block; - margin-bottom: 4px; -} -.cap-detail-item .cap-status { - font-size: 10px; - color: var(--text-muted); -} -.cap-detail-item .cap-detail { - font-size: 9px; - color: var(--text-muted); - display: block; - margin-top: 4px; - font-family: 'JetBrains Mono', monospace; -} -.cap-can-list, .cap-cannot-list { - list-style: none; - padding: 0; - margin: 0; -} -.cap-can-list li, .cap-cannot-list li { - padding: 6px 0; - font-size: 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); -} -.cap-can-list li:last-child, .cap-cannot-list li:last-child { - border-bottom: none; -} - -/* Modal Header Classification Colors */ -.device-detail-header.classification-cyan { - background: linear-gradient(135deg, rgba(0, 204, 255, 0.2) 0%, rgba(0, 150, 200, 0.1) 100%); - border-bottom: 2px solid #00ccff; -} -.device-detail-header.classification-orange { - background: linear-gradient(135deg, rgba(255, 153, 51, 0.2) 0%, rgba(200, 120, 40, 0.1) 100%); - border-bottom: 2px solid #ff9933; -} -.device-detail-header.classification-green { - background: linear-gradient(135deg, rgba(0, 204, 0, 0.2) 0%, rgba(0, 150, 0, 0.1) 100%); - border-bottom: 2px solid #00cc00; -} - -/* Playbook Enhancements */ -.playbook-item { - cursor: pointer; - transition: all 0.2s; -} -.playbook-item:hover { - background: rgba(255, 153, 51, 0.1); -} -.playbook-category { - font-size: 9px; - padding: 2px 6px; - background: rgba(255, 153, 51, 0.2); - color: #ff9933; - border-radius: 3px; - text-transform: uppercase; -} -.playbook-meta { - font-size: 10px; - color: var(--text-muted); - margin-top: 8px; -} -.playbook-warning { - padding: 8px 12px; - background: rgba(255, 153, 51, 0.15); - border: 1px solid rgba(255, 153, 51, 0.3); - border-radius: 4px; - font-size: 11px; - margin-top: 8px; -} - -/* Case Status Enhancements */ -.case-date { - font-size: 10px; - color: var(--text-muted); - margin-top: 4px; -} - -/* Known Device Type Badge */ -.known-device-type { - font-size: 9px; - padding: 2px 6px; - background: rgba(74, 158, 255, 0.2); - color: #4a9eff; - border-radius: 3px; - margin-left: 8px; -} - -/* ========================================================================== - Icon System - Minimal, functional icons that replace words. No decoration. - Designed for screenshot legibility in reports. - ========================================================================== */ - -.icon { - display: inline-block; - width: 16px; - height: 16px; - vertical-align: middle; - flex-shrink: 0; -} - -.icon svg { - width: 100%; - height: 100%; -} - -.icon--sm { - width: 12px; - height: 12px; -} - -.icon--lg { - width: 20px; - height: 20px; -} - -/* Signal Type Icons */ -.icon-wifi svg, -.icon-bluetooth svg, -.icon-cellular svg, -.icon-signal-unknown svg { - fill: var(--text-secondary); -} - -/* Recording State */ -.icon-recording { - color: #ff3366; -} - -.icon-recording.active svg { - animation: recording-pulse 1.5s ease-in-out infinite; -} - -@keyframes recording-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -/* Anomaly Indicator */ -.icon-anomaly { - color: #ff9933; -} - -.icon-anomaly.critical { - color: #ff3366; -} - -/* Export Icon */ -.icon-export { - color: var(--text-secondary); -} - -/* Classification Dots - replaces emoji circles for risk levels */ -.classification-dot { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - vertical-align: middle; - margin-right: 4px; -} - -.classification-dot.high { - background-color: var(--accent-red); -} - -.classification-dot.review { - background-color: var(--accent-orange); -} - -.classification-dot.info { - background-color: var(--accent-green); -} - -/* Device Indicators with Icons */ -.device-indicator-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - opacity: 0.3; - transition: opacity 0.3s, transform 0.3s; -} - -.device-indicator-icon.active { - opacity: 1; - animation: device-pulse 1.5s ease-in-out infinite; -} - -.device-indicator-icon.inactive { - opacity: 0.2; -} - -.device-indicator-icon .icon { - width: 18px; - height: 18px; -} - -/* Protocol badge with icon */ -.protocol-icon-badge { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 10px; - padding: 2px 6px; - background: rgba(255, 255, 255, 0.1); - border-radius: 3px; - text-transform: uppercase; -} - -.protocol-icon-badge .icon { - width: 12px; - height: 12px; -} - -/* Recording status indicator */ -.recording-status { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 11px; -} - -.recording-status .icon-recording { - width: 10px; - height: 10px; -} - -.recording-status.active { - color: #ff3366; - font-weight: 600; -} - -/* Anomaly flag in device items */ -.anomaly-flag { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 6px; - border-radius: 3px; - font-size: 9px; - font-weight: 600; - text-transform: uppercase; -} - -.anomaly-flag.needs-review { - background: rgba(255, 153, 51, 0.2); - color: #ff9933; -} - -.anomaly-flag.high-interest { - background: rgba(255, 51, 51, 0.2); - color: #ff3333; -} - -.anomaly-flag .icon { - width: 10px; - height: 10px; -} +/* TSCM Styles */ + +/* TSCM Threat Cards */ +.threat-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px; + border-radius: 6px; + background: rgba(0,0,0,0.3); + border: 1px solid var(--border-color); +} +.threat-card .count { + font-size: 18px; + font-weight: bold; + line-height: 1; +} +.threat-card .label { + font-size: 9px; + text-transform: uppercase; + opacity: 0.7; + margin-top: 2px; +} +.threat-card.critical { border-color: #ff3366; color: #ff3366; } +.threat-card.critical.active { background: rgba(255,51,102,0.2); } +.threat-card.high { border-color: #ff9933; color: #ff9933; } +.threat-card.high.active { background: rgba(255,153,51,0.2); } +.threat-card.medium { border-color: #ffcc00; color: #ffcc00; } +.threat-card.medium.active { background: rgba(255,204,0,0.2); } +.threat-card.low { border-color: #00ff88; color: #00ff88; } +.threat-card.low.active { background: rgba(0,255,136,0.2); } + +/* TSCM Dashboard */ +.tscm-dashboard { + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + padding-bottom: 80px; /* Space for status bar */ +} +.tscm-threat-banner { + display: flex; + gap: 12px; + padding: 12px; + background: rgba(0,0,0,0.3); + border-radius: 8px; +} +.tscm-threat-banner .threat-card { + flex: 1; + padding: 12px; +} +.tscm-threat-banner .threat-card .count { + font-size: 24px; +} +.tscm-main-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.tscm-panel { + background: rgba(0,0,0,0.3); + border: 1px solid var(--border-color); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 200px; + height: 200px; +} +/* Full-width panels (like Detected Threats) get more height */ +.tscm-panel[style*="grid-column: span 2"] { + min-height: 150px; + height: 150px; +} +.tscm-panel-header { + padding: 10px 12px; + background: rgba(0,0,0,0.3); + border-bottom: 1px solid var(--border-color); + font-weight: 600; + font-size: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} +.tscm-panel-header .badge { + background: var(--primary-color); + color: #fff; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: normal; +} +.tscm-panel-content { + flex: 1; + overflow-y: auto; + padding: 8px; +} +.tscm-device-item { + padding: 8px 10px; + border-radius: 4px; + margin-bottom: 6px; + background: rgba(0,0,0,0.2); + border-left: 3px solid var(--border-color); + cursor: pointer; + transition: background 0.2s; +} +.tscm-device-item:hover { + background: rgba(74,158,255,0.1); +} +.tscm-device-item.new { + border-left-color: #ff9933; + animation: pulse-glow 2s infinite; +} +.tscm-device-item.threat { + border-left-color: #ff3366; +} +.tscm-device-item.baseline { + border-left-color: #00ff88; +} +/* Classification colors */ +.tscm-device-item.classification-green { + border-left-color: #00cc00; + background: rgba(0, 204, 0, 0.1); +} +.tscm-device-item.classification-yellow { + border-left-color: #ffcc00; + background: rgba(255, 204, 0, 0.1); +} +.tscm-device-item.classification-red { + border-left-color: #ff3333; + background: rgba(255, 51, 51, 0.15); + animation: pulse-glow 2s infinite; +} +.classification-indicator { + margin-right: 6px; +} +.tscm-status-message { + padding: 12px; + background: rgba(255, 153, 51, 0.15); + border: 1px solid rgba(255, 153, 51, 0.3); + border-radius: 6px; + color: var(--text-primary); + font-size: 12px; + display: flex; + align-items: center; + gap: 8px; +} +.tscm-status-message .status-icon { + font-size: 16px; +} +.tscm-privilege-warning { + padding: 10px 12px; + background: rgba(255, 51, 51, 0.15); + border: 1px solid rgba(255, 51, 51, 0.4); + border-radius: 6px; + color: var(--text-primary); + font-size: 11px; + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 12px; +} +.tscm-privilege-warning .warning-icon { + font-size: 18px; + flex-shrink: 0; +} +.tscm-privilege-warning .warning-action { + margin-top: 4px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--accent-cyan); + background: rgba(0, 0, 0, 0.3); + padding: 4px 8px; + border-radius: 3px; +} +.tscm-action-btn { + padding: 10px 16px; + background: var(--accent-green); + border: none; + border-radius: 4px; + color: #000; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} +.tscm-action-btn:hover { + background: #2ecc71; + transform: translateY(-1px); +} +.tscm-device-reasons { + font-size: 10px; + color: var(--text-secondary); + margin-top: 4px; + font-style: italic; + line-height: 1.4; +} +.audio-badge { + margin-left: 6px; + font-size: 10px; +} +.tscm-device-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} +.tscm-device-name { + font-weight: 600; + font-size: 12px; +} +.tscm-device-meta { + font-size: 10px; + color: var(--text-muted); + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.tscm-device-indicators { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.indicator-tag { + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + white-space: nowrap; +} +.score-badge { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 600; +} +.score-badge.score-low { + background: rgba(0, 204, 0, 0.2); + color: #00cc00; +} +.score-badge.score-medium { + background: rgba(255, 204, 0, 0.2); + color: #ffcc00; +} +.score-badge.score-high { + background: rgba(255, 51, 51, 0.2); + color: #ff3333; +} +.tscm-action { + margin-top: 4px; + font-size: 10px; + color: #ff9933; + font-weight: 600; + text-transform: uppercase; +} +.tscm-correlations { + margin-top: 16px; + padding: 12px; + background: rgba(255, 153, 51, 0.1); + border-radius: 6px; + border: 1px solid #ff9933; +} +.tscm-correlations h4 { + margin: 0 0 8px 0; + font-size: 12px; + color: #ff9933; +} +.correlation-item { + padding: 8px; + margin-bottom: 6px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + font-size: 11px; +} +.correlation-devices { + font-size: 10px; + color: var(--text-muted); + margin-top: 4px; +} +.tscm-summary-box { + display: flex; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} +.summary-stat { + flex: 1; + min-width: 100px; + padding: 12px; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + text-align: center; +} +.summary-stat .count { + font-size: 24px; + font-weight: 700; +} +.summary-stat .label { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; +} +.summary-stat.high-interest .count { color: #ff3333; } +.summary-stat.needs-review .count { color: #ffcc00; } +.summary-stat.informational .count { color: #00cc00; } +.tscm-assessment { + padding: 10px 14px; + margin: 12px 0; + border-radius: 6px; + font-size: 13px; +} +.tscm-assessment.high-interest { + background: rgba(255, 51, 51, 0.15); + border: 1px solid #ff3333; + color: #ff3333; +} +.tscm-assessment.needs-review { + background: rgba(255, 204, 0, 0.15); + border: 1px solid #ffcc00; + color: #ffcc00; +} +.tscm-assessment.informational { + background: rgba(0, 204, 0, 0.15); + border: 1px solid #00cc00; + color: #00cc00; +} +.tscm-disclaimer { + font-size: 10px; + color: var(--text-muted); + font-style: italic; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + margin-top: 8px; +} + +/* TSCM Device Details Modal */ +.tscm-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} +.tscm-modal { + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: 8px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + position: relative; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); +} +.tscm-modal-close { + position: absolute; + top: 12px; + right: 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 20px; + cursor: pointer; + z-index: 10; + transition: all 0.2s; +} +.tscm-modal-close:hover { + background: var(--accent-red); + border-color: var(--accent-red); + color: #fff; +} +.device-detail-header { + padding: 16px; + padding-right: 52px; /* Reserve space for close button */ + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} +.device-detail-header h3 { + margin: 0; + font-size: 16px; +} +.device-detail-header.classification-red { background: rgba(255, 51, 51, 0.15); } +.device-detail-header.classification-yellow { background: rgba(255, 204, 0, 0.15); } +.device-detail-header.classification-green { background: rgba(0, 204, 0, 0.15); } +.device-detail-protocol { + font-size: 10px; + padding: 3px 8px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + text-transform: uppercase; +} +.device-detail-score { + display: flex; + align-items: center; + padding: 16px; + gap: 16px; + border-bottom: 1px solid var(--border-color); +} +.score-circle { + width: 70px; + height: 70px; + border-radius: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 3px solid; +} +.score-circle.high { border-color: #ff3333; background: rgba(255, 51, 51, 0.1); } +.score-circle.medium { border-color: #ffcc00; background: rgba(255, 204, 0, 0.1); } +.score-circle.low { border-color: #00cc00; background: rgba(0, 204, 0, 0.1); } +.score-circle .score-value { + font-size: 24px; + font-weight: 700; +} +.score-circle.high .score-value { color: #ff3333; } +.score-circle.medium .score-value { color: #ffcc00; } +.score-circle.low .score-value { color: #00cc00; } +.score-circle .score-label { + font-size: 8px; + color: var(--text-muted); + text-transform: uppercase; +} +.score-breakdown { + flex: 1; + font-size: 12px; + line-height: 1.6; +} +.device-detail-section { + padding: 16px; + border-bottom: 1px solid var(--border-color); +} +.device-detail-section h4 { + margin: 0 0 12px 0; + font-size: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.device-detail-table { + width: 100%; + font-size: 12px; +} +.device-detail-table td { + padding: 4px 0; +} +.device-detail-table td:first-child { + color: var(--text-dim); + width: 40%; +} +.indicator-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.indicator-item { + display: flex; + gap: 10px; + padding: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + font-size: 11px; +} +.indicator-type { + background: rgba(255, 153, 51, 0.2); + color: #ff9933; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + white-space: nowrap; +} +.indicator-desc { + color: var(--text-color); +} +.device-reasons-list { + margin: 0; + padding-left: 20px; + font-size: 12px; + color: var(--text-primary); +} +.device-reasons-list li { + margin-bottom: 4px; + color: var(--text-secondary); +} +.device-detail-disclaimer { + padding: 12px 16px; + font-size: 10px; + color: var(--text-secondary); + background: rgba(74, 158, 255, 0.1); + border-top: 1px solid rgba(74, 158, 255, 0.3); +} +.tscm-threat-action { + margin-top: 6px; + font-size: 10px; + color: #ff9933; + text-transform: uppercase; + font-weight: 600; +} +.tscm-device-item { + cursor: pointer; +} +.tscm-device-item:hover { + background: rgba(255, 255, 255, 0.05); +} +.threat-card.clickable { + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; +} +.threat-card.clickable:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} +.category-device-list { + max-height: 400px; + overflow-y: auto; +} +.category-device-item { + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: background 0.2s; +} +.category-device-item:hover { + background: rgba(255, 255, 255, 0.05); +} +.category-device-header { + display: flex; + justify-content: space-between; + align-items: center; +} +.category-device-name { + font-weight: 600; + font-size: 13px; +} +.category-device-score { + background: rgba(255, 255, 255, 0.1); + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} +.category-device-meta { + display: flex; + gap: 6px; + margin-top: 6px; +} +.protocol-badge { + font-size: 9px; + padding: 2px 6px; + background: rgba(74, 158, 255, 0.2); + color: #4a9eff; + border-radius: 3px; + text-transform: uppercase; +} +.indicator-mini { + font-size: 9px; + padding: 2px 6px; + background: rgba(255, 153, 51, 0.2); + color: #ff9933; + border-radius: 3px; +} +.correlation-detail-item { + padding: 12px; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + margin-bottom: 8px; +} +.tscm-threat-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.tscm-threat-item { + padding: 10px 12px; + border-radius: 6px; + background: rgba(0,0,0,0.2); + border: 1px solid; +} +.tscm-threat-item.critical { border-color: #ff3366; background: rgba(255,51,102,0.1); } +.tscm-threat-item.high { border-color: #ff9933; background: rgba(255,153,51,0.1); } +.tscm-threat-item.medium { border-color: #ffcc00; background: rgba(255,204,0,0.1); } +.tscm-threat-item.low { border-color: #00ff88; background: rgba(0,255,136,0.1); } +.tscm-threat-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} +.tscm-threat-type { + font-weight: 600; + font-size: 12px; +} +.tscm-threat-severity { + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; +} +.tscm-threat-item.critical .tscm-threat-severity { background: #ff3366; color: #fff; } +.tscm-threat-item.high .tscm-threat-severity { background: #ff9933; color: #000; } +.tscm-threat-item.medium .tscm-threat-severity { background: #ffcc00; color: #000; } +.tscm-threat-item.low .tscm-threat-severity { background: #00ff88; color: #000; } +.tscm-threat-details { + font-size: 11px; + color: var(--text-muted); +} +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 5px rgba(255,153,51,0.3); } + 50% { box-shadow: 0 0 15px rgba(255,153,51,0.6); } +} +.tscm-empty { + text-align: center; + padding: 30px; + color: var(--text-muted); + font-size: 12px; +} +.tscm-empty-primary { + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.tscm-empty-secondary { + font-size: 10px; + color: var(--text-muted); + max-width: 280px; + margin: 0 auto; + line-height: 1.4; +} + +/* Futuristic Scanner Progress */ +.tscm-scanner-progress { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + margin-top: 10px; + background: rgba(0,0,0,0.4); + border: 1px solid var(--border-color); + border-radius: 8px; +} +.scanner-ring { + position: relative; + width: 70px; + height: 70px; + flex-shrink: 0; +} +.scanner-ring svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} +.scanner-track { + fill: none; + stroke: rgba(74,158,255,0.1); + stroke-width: 4; +} +.scanner-progress { + fill: none; + stroke: var(--accent-cyan); + stroke-width: 4; + stroke-linecap: round; + stroke-dasharray: 283; + stroke-dashoffset: 283; + transition: stroke-dashoffset 0.3s ease; + filter: drop-shadow(0 0 6px var(--accent-cyan)); +} +.scanner-sweep { + stroke: var(--accent-cyan); + stroke-width: 2; + opacity: 0.8; + transform-origin: 50px 50px; + animation: sweep-rotate 2s linear infinite; + filter: drop-shadow(0 0 4px var(--accent-cyan)); +} +@keyframes sweep-rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.scanner-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} +.scanner-percent { + font-size: 14px; + font-weight: bold; + color: var(--accent-cyan); + text-shadow: 0 0 10px var(--accent-cyan); +} +.scanner-info { + flex: 1; + min-width: 0; +} +.scanner-status { + font-size: 10px; + font-weight: 600; + letter-spacing: 2px; + color: var(--accent-cyan); + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.scanner-devices { + display: flex; + gap: 8px; +} +.device-indicator { + font-size: 14px; + opacity: 0.3; + transition: opacity 0.3s, transform 0.3s; +} +.device-indicator.active { + opacity: 1; + animation: device-pulse 1.5s ease-in-out infinite; +} +.device-indicator.inactive { + opacity: 0.2; + filter: grayscale(1); +} +@keyframes device-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +/* Meeting Window Banner */ +.tscm-meeting-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + margin-bottom: 12px; + background: linear-gradient(90deg, rgba(255, 51, 102, 0.2), rgba(255, 153, 51, 0.2)); + border: 1px solid rgba(255, 51, 102, 0.5); + border-radius: 6px; + animation: meeting-glow 2s ease-in-out infinite; +} +@keyframes meeting-glow { + 0%, 100% { box-shadow: 0 0 5px rgba(255, 51, 102, 0.3); } + 50% { box-shadow: 0 0 15px rgba(255, 51, 102, 0.5); } +} +.meeting-indicator { + display: flex; + align-items: center; + gap: 8px; +} +.meeting-pulse { + width: 10px; + height: 10px; + background: #ff3366; + border-radius: 50%; + animation: pulse-dot 1.5s ease-in-out infinite; +} +@keyframes pulse-dot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.2); } +} +.meeting-text { + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + color: #ff3366; + text-transform: uppercase; +} +.meeting-info { + font-size: 11px; + color: var(--text-secondary); + display: flex; + gap: 12px; +} + +/* Capabilities Bar */ +.tscm-capabilities-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 12px; + margin-bottom: 12px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 11px; +} +.cap-item { + display: flex; + align-items: center; + gap: 4px; +} +.cap-icon { + font-size: 14px; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.cap-icon svg { + width: 100%; + height: 100%; + stroke: currentColor; + fill: none; +} +.cap-status { + color: var(--text-muted); + font-size: 10px; + text-transform: uppercase; +} +.cap-status.available { color: #00cc00; } +.cap-status.limited { color: #ffcc00; } +.cap-status.unavailable { color: #ff3333; } +.cap-limitations { + margin-left: auto; + display: flex; + align-items: center; + gap: 4px; + color: #ff9933; + font-size: 10px; +} +.cap-warn { + font-size: 12px; +} + +/* Baseline Health Indicator */ +.tscm-baseline-health { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + margin-bottom: 12px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + font-size: 11px; +} +.health-label { + color: var(--text-muted); +} +.health-name { + color: var(--text-primary); + font-weight: 600; +} +.health-badge { + padding: 2px 8px; + border-radius: 10px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; +} +.health-badge.healthy { + background: rgba(0, 204, 0, 0.2); + color: #00cc00; +} +.health-badge.noisy { + background: rgba(255, 204, 0, 0.2); + color: #ffcc00; +} +.health-badge.stale { + background: rgba(255, 51, 51, 0.2); + color: #ff3333; +} +.health-age { + color: var(--text-muted); + font-size: 10px; + margin-left: auto; +} + +/* Advanced Modal Styles */ +.tscm-advanced-modal { + max-width: 600px; +} +.tscm-modal-header { + padding: 16px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} +.tscm-modal-header h3 { + margin: 0; + font-size: 16px; +} +.tscm-modal-body { + padding: 16px; + max-height: 60vh; + overflow-y: auto; +} +.tscm-modal-section { + margin-bottom: 16px; +} +.tscm-modal-section h4 { + margin: 0 0 8px 0; + font-size: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Capabilities Detail */ +.cap-detail-item { + padding: 10px; + margin-bottom: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + border-left: 3px solid var(--border-color); +} +.cap-detail-item.available { border-left-color: #00cc00; } +.cap-detail-item.limited { border-left-color: #ffcc00; } +.cap-detail-item.unavailable { border-left-color: #ff3333; } +.cap-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} +.cap-detail-name { + font-weight: 600; + font-size: 12px; +} +.cap-detail-status { + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; +} +.cap-detail-status.available { background: rgba(0, 204, 0, 0.2); color: #00cc00; } +.cap-detail-status.limited { background: rgba(255, 204, 0, 0.2); color: #ffcc00; } +.cap-detail-status.unavailable { background: rgba(255, 51, 51, 0.2); color: #ff3333; } +.cap-detail-limits { + font-size: 10px; + color: var(--text-muted); + margin-top: 4px; +} +.cap-detail-limits li { + margin-bottom: 2px; +} + +/* Known Devices List */ +.known-device-item { + padding: 10px; + margin-bottom: 6px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + border-left: 3px solid #00cc00; + display: flex; + justify-content: space-between; + align-items: center; +} +.known-device-info { + flex: 1; +} +.known-device-name { + font-weight: 600; + font-size: 12px; +} +.known-device-id { + font-size: 10px; + color: var(--text-muted); + font-family: var(--font-mono); +} +.known-device-actions { + display: flex; + gap: 6px; +} +.known-device-btn { + padding: 4px 8px; + font-size: 10px; + border: none; + border-radius: 3px; + cursor: pointer; +} +.known-device-btn.remove { + background: rgba(255, 51, 51, 0.2); + color: #ff3333; +} +.known-device-btn.remove:hover { + background: rgba(255, 51, 51, 0.4); +} + +/* Cases List */ +.case-item { + padding: 12px; + margin-bottom: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + border-left: 3px solid var(--primary-color); + cursor: pointer; + transition: background 0.2s; +} +.case-item:hover { + background: rgba(74, 158, 255, 0.1); +} +.case-item.priority-high { border-left-color: #ff3333; } +.case-item.priority-normal { border-left-color: #4a9eff; } +.case-item.priority-low { border-left-color: #00cc00; } +.case-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} +.case-name { + font-weight: 600; + font-size: 13px; +} +.case-status { + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; +} +.case-status.open { background: rgba(0, 204, 0, 0.2); color: #00cc00; } +.case-status.closed { background: rgba(128, 128, 128, 0.2); color: #888; } +.case-meta { + font-size: 10px; + color: var(--text-muted); + display: flex; + gap: 12px; +} + +/* Playbook Styles */ +.playbook-item { + padding: 12px; + margin-bottom: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + border-left: 3px solid #ff9933; +} +.playbook-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} +.playbook-title { + font-weight: 600; + font-size: 13px; +} +.playbook-risk { + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; +} +.playbook-risk.high_interest { background: rgba(255, 51, 51, 0.2); color: #ff3333; } +.playbook-risk.needs_review { background: rgba(255, 204, 0, 0.2); color: #ffcc00; } +.playbook-risk.informational { background: rgba(0, 204, 0, 0.2); color: #00cc00; } +.playbook-desc { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 8px; +} +.playbook-steps { + font-size: 11px; +} +.playbook-step { + padding: 6px 8px; + margin-bottom: 4px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} +.playbook-step-num { + color: #ff9933; + font-weight: 600; + margin-right: 6px; +} + +/* Timeline Styles */ +.timeline-container { + padding: 12px; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; +} +.timeline-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} +.timeline-device-name { + font-weight: 600; + font-size: 14px; +} +.timeline-metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin-bottom: 12px; +} +.timeline-metric { + padding: 8px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + text-align: center; +} +.timeline-metric-value { + font-size: 16px; + font-weight: 700; + color: var(--accent-cyan); +} +.timeline-metric-label { + font-size: 9px; + color: var(--text-muted); + text-transform: uppercase; +} +.timeline-chart { + height: 60px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + position: relative; + overflow: hidden; +} +.timeline-bar { + position: absolute; + bottom: 0; + width: 3px; + background: var(--accent-cyan); + border-radius: 2px 2px 0 0; +} + +/* Proximity Badge */ +.proximity-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; +} +.proximity-badge.very_close { + background: rgba(255, 51, 51, 0.2); + color: #ff3333; +} +.proximity-badge.close { + background: rgba(255, 153, 51, 0.2); + color: #ff9933; +} +.proximity-badge.moderate { + background: rgba(255, 204, 0, 0.2); + color: #ffcc00; +} +.proximity-badge.far { + background: rgba(0, 204, 0, 0.2); + color: #00cc00; +} + +/* Add to Known Device Button */ +.add-known-btn { + padding: 4px 8px; + font-size: 10px; + background: rgba(0, 204, 0, 0.2); + color: #00cc00; + border: 1px solid rgba(0, 204, 0, 0.3); + border-radius: 3px; + cursor: pointer; + transition: all 0.2s; +} +.add-known-btn:hover { + background: rgba(0, 204, 0, 0.3); +} + +/* Capabilities Grid */ +.capabilities-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 16px; +} +.cap-detail-item .cap-icon { + font-size: 24px; + display: block; + margin-bottom: 8px; + width: 24px; + height: 24px; +} +.cap-detail-item .cap-icon svg { + width: 100%; + height: 100%; + stroke: currentColor; + fill: none; +} +.cap-detail-item .cap-name { + font-weight: 600; + font-size: 12px; + display: block; + margin-bottom: 4px; +} +.cap-detail-item .cap-status { + font-size: 10px; + color: var(--text-muted); +} +.cap-detail-item .cap-detail { + font-size: 9px; + color: var(--text-muted); + display: block; + margin-top: 4px; + font-family: var(--font-mono); +} +.cap-can-list, .cap-cannot-list { + list-style: none; + padding: 0; + margin: 0; +} +.cap-can-list li, .cap-cannot-list li { + padding: 6px 0; + font-size: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} +.cap-can-list li:last-child, .cap-cannot-list li:last-child { + border-bottom: none; +} + +/* Modal Header Classification Colors */ +.device-detail-header.classification-cyan { + background: linear-gradient(135deg, rgba(0, 204, 255, 0.2) 0%, rgba(0, 150, 200, 0.1) 100%); + border-bottom: 2px solid #00ccff; +} +.device-detail-header.classification-orange { + background: linear-gradient(135deg, rgba(255, 153, 51, 0.2) 0%, rgba(200, 120, 40, 0.1) 100%); + border-bottom: 2px solid #ff9933; +} +.device-detail-header.classification-green { + background: linear-gradient(135deg, rgba(0, 204, 0, 0.2) 0%, rgba(0, 150, 0, 0.1) 100%); + border-bottom: 2px solid #00cc00; +} + +/* Playbook Enhancements */ +.playbook-item { + cursor: pointer; + transition: all 0.2s; +} +.playbook-item:hover { + background: rgba(255, 153, 51, 0.1); +} +.playbook-category { + font-size: 9px; + padding: 2px 6px; + background: rgba(255, 153, 51, 0.2); + color: #ff9933; + border-radius: 3px; + text-transform: uppercase; +} +.playbook-meta { + font-size: 10px; + color: var(--text-muted); + margin-top: 8px; +} +.playbook-warning { + padding: 8px 12px; + background: rgba(255, 153, 51, 0.15); + border: 1px solid rgba(255, 153, 51, 0.3); + border-radius: 4px; + font-size: 11px; + margin-top: 8px; +} + +/* Case Status Enhancements */ +.case-date { + font-size: 10px; + color: var(--text-muted); + margin-top: 4px; +} + +/* Known Device Type Badge */ +.known-device-type { + font-size: 9px; + padding: 2px 6px; + background: rgba(74, 158, 255, 0.2); + color: #4a9eff; + border-radius: 3px; + margin-left: 8px; +} + +/* ========================================================================== + Icon System + Minimal, functional icons that replace words. No decoration. + Designed for screenshot legibility in reports. + ========================================================================== */ + +.icon { + display: inline-block; + width: 16px; + height: 16px; + vertical-align: middle; + flex-shrink: 0; +} + +.icon svg { + width: 100%; + height: 100%; +} + +.icon--sm { + width: 12px; + height: 12px; +} + +.icon--lg { + width: 20px; + height: 20px; +} + +/* Signal Type Icons */ +.icon-wifi svg, +.icon-bluetooth svg, +.icon-cellular svg, +.icon-signal-unknown svg { + fill: var(--text-secondary); +} + +/* Recording State */ +.icon-recording { + color: #ff3366; +} + +.icon-recording.active svg { + animation: recording-pulse 1.5s ease-in-out infinite; +} + +@keyframes recording-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Anomaly Indicator */ +.icon-anomaly { + color: #ff9933; +} + +.icon-anomaly.critical { + color: #ff3366; +} + +/* Export Icon */ +.icon-export { + color: var(--text-secondary); +} + +/* Classification Dots - replaces emoji circles for risk levels */ +.classification-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + vertical-align: middle; + margin-right: 4px; +} + +.classification-dot.high { + background-color: var(--accent-red); +} + +.classification-dot.review { + background-color: var(--accent-orange); +} + +.classification-dot.info { + background-color: var(--accent-green); +} + +/* Device Indicators with Icons */ +.device-indicator-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + opacity: 0.3; + transition: opacity 0.3s, transform 0.3s; +} + +.device-indicator-icon.active { + opacity: 1; + animation: device-pulse 1.5s ease-in-out infinite; +} + +.device-indicator-icon.inactive { + opacity: 0.2; +} + +.device-indicator-icon .icon { + width: 18px; + height: 18px; +} + +/* Protocol badge with icon */ +.protocol-icon-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + padding: 2px 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + text-transform: uppercase; +} + +.protocol-icon-badge .icon { + width: 12px; + height: 12px; +} + +/* Recording status indicator */ +.recording-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; +} + +.recording-status .icon-recording { + width: 10px; + height: 10px; +} + +.recording-status.active { + color: #ff3366; + font-weight: 600; +} + +/* Anomaly flag in device items */ +.anomaly-flag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 3px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; +} + +.anomaly-flag.needs-review { + background: rgba(255, 153, 51, 0.2); + color: #ff9933; +} + +.anomaly-flag.high-interest { + background: rgba(255, 51, 51, 0.2); + color: #ff3333; +} + +.anomaly-flag .icon { + width: 10px; + height: 10px; +} diff --git a/static/css/responsive.css b/static/css/responsive.css index 82eb03f..9f4223f 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -1,7 +1,16 @@ -/* ============================================ - RESPONSIVE UTILITIES - iNTERCEPT - Shared responsive foundation for all pages - ============================================ */ +/* ============================================ + RESPONSIVE UTILITIES - iNTERCEPT + Shared responsive foundation for all pages + ============================================ */ + +/* Terminus - bundled monospace */ +@font-face { + font-family: 'Terminus'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/static/vendor/fonts/Terminus.ttf') format('truetype'); +} /* ============== CSS VARIABLES ============== */ :root { diff --git a/static/css/satellite_dashboard.css b/static/css/satellite_dashboard.css index 02c2790..72b110c 100644 --- a/static/css/satellite_dashboard.css +++ b/static/css/satellite_dashboard.css @@ -142,7 +142,7 @@ body { border: 1px solid rgba(0, 212, 255, 0.3); border-radius: 4px; padding: 4px 10px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; } @@ -164,7 +164,7 @@ body { display: flex; gap: 20px; align-items: center; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; } @@ -457,7 +457,7 @@ body { } .telemetry-value { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; color: var(--accent-cyan); } @@ -543,7 +543,7 @@ body { } .pass-time { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); } /* Bottom controls bar */ @@ -579,7 +579,7 @@ body { border: 1px solid rgba(0, 212, 255, 0.3); border-radius: 4px; color: var(--accent-cyan); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 11px; } diff --git a/static/css/settings.css b/static/css/settings.css index 1ce725c..ac3d2e7 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -1,444 +1,444 @@ -/* Settings Modal Styles */ - -.settings-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.85); - z-index: 10000; - overflow-y: auto; - backdrop-filter: blur(4px); -} - -.settings-modal.active { - display: flex; - justify-content: center; - align-items: flex-start; - padding: 40px 20px; -} - -.settings-content { - background: var(--bg-dark, #0a0a0f); - border: 1px solid var(--border-color, #1a1a2e); - border-radius: 8px; - max-width: 600px; - width: 100%; - position: relative; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); -} - -.settings-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--border-color, #1a1a2e); -} - -.settings-header h2 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: var(--text-primary, #e0e0e0); - display: flex; - align-items: center; - gap: 8px; -} - -.settings-header h2 .icon { - width: 20px; - height: 20px; - color: var(--accent-cyan, #00d4ff); -} - -.settings-close { - background: none; - border: none; - color: var(--text-muted, #666); - font-size: 24px; - cursor: pointer; - padding: 4px; - line-height: 1; - transition: color 0.2s; -} - -.settings-close:hover { - color: var(--accent-red, #ff4444); -} - -/* Settings Tabs */ -.settings-tabs { - display: flex; - border-bottom: 1px solid var(--border-color, #1a1a2e); - padding: 0 20px; - gap: 4px; -} - -.settings-tab { - background: none; - border: none; - padding: 12px 16px; - color: var(--text-muted, #666); - font-size: 13px; - font-weight: 500; - cursor: pointer; - position: relative; - transition: color 0.2s; -} - -.settings-tab:hover { - color: var(--text-primary, #e0e0e0); -} - -.settings-tab.active { - color: var(--accent-cyan, #00d4ff); -} - -.settings-tab.active::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background: var(--accent-cyan, #00d4ff); -} - -/* Settings Sections */ -.settings-section { - display: none; - padding: 20px; -} - -.settings-section.active { - display: block; -} - -.settings-group { - margin-bottom: 24px; -} - -.settings-group:last-child { - margin-bottom: 0; -} - -.settings-group-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-muted, #666); - margin-bottom: 12px; -} - -/* Settings Row */ -.settings-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); -} - -.settings-row:last-child { - border-bottom: none; -} - -.settings-label { - display: flex; - flex-direction: column; - gap: 2px; -} - -.settings-label-text { - font-size: 13px; - color: var(--text-primary, #e0e0e0); -} - -.settings-label-desc { - font-size: 11px; - color: var(--text-muted, #666); -} - -/* Toggle Switch */ -.toggle-switch { - position: relative; - width: 44px; - height: 24px; - flex-shrink: 0; -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--bg-tertiary, #1a1a2e); - border: 1px solid var(--border-color, #2a2a3e); - transition: 0.3s; - border-radius: 24px; -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 2px; - bottom: 2px; - background-color: var(--text-muted, #666); - transition: 0.3s; - border-radius: 50%; -} - -.toggle-switch input:checked + .toggle-slider { - background-color: var(--accent-cyan, #00d4ff); - border-color: var(--accent-cyan, #00d4ff); -} - -.toggle-switch input:checked + .toggle-slider:before { - transform: translateX(20px); - background-color: white; -} - -.toggle-switch input:focus + .toggle-slider { - box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3); -} - -/* Select Dropdown */ -.settings-select { - background: var(--bg-tertiary, #1a1a2e); - border: 1px solid var(--border-color, #2a2a3e); - border-radius: 4px; - padding: 8px 12px; - font-size: 13px; - color: var(--text-primary, #e0e0e0); - min-width: 160px; - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - padding-right: 32px; -} - -.settings-select:focus { - outline: none; - border-color: var(--accent-cyan, #00d4ff); -} - -/* Text Input */ -.settings-input { - background: var(--bg-tertiary, #1a1a2e); - border: 1px solid var(--border-color, #2a2a3e); - border-radius: 4px; - padding: 8px 12px; - font-size: 13px; - color: var(--text-primary, #e0e0e0); - width: 200px; -} - -.settings-input:focus { - outline: none; - border-color: var(--accent-cyan, #00d4ff); -} - -.settings-input::placeholder { - color: var(--text-muted, #666); -} - -/* Asset Status */ -.asset-status { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 12px; - padding: 12px; - background: var(--bg-secondary, #0f0f1a); - border-radius: 6px; -} - -.asset-status-row { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 12px; -} - -.asset-name { - color: var(--text-muted, #888); -} - -.asset-badge { - padding: 2px 8px; - border-radius: 10px; - font-size: 10px; - font-weight: 500; - text-transform: uppercase; -} - -.asset-badge.available { - background: rgba(0, 255, 136, 0.15); - color: var(--accent-green, #00ff88); -} - -.asset-badge.missing { - background: rgba(255, 68, 68, 0.15); - color: var(--accent-red, #ff4444); -} - -.asset-badge.checking { - background: rgba(255, 170, 0, 0.15); - color: var(--accent-orange, #ffaa00); -} - -/* Check Assets Button */ -.check-assets-btn { - background: var(--bg-tertiary, #1a1a2e); - border: 1px solid var(--border-color, #2a2a3e); - color: var(--text-primary, #e0e0e0); - padding: 8px 16px; - border-radius: 4px; - font-size: 12px; - cursor: pointer; - margin-top: 12px; - transition: all 0.2s; -} - -.check-assets-btn:hover { - border-color: var(--accent-cyan, #00d4ff); - color: var(--accent-cyan, #00d4ff); -} - -.check-assets-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* GPS Detection Spinner */ -.detecting-spinner { - display: inline-block; - width: 12px; - height: 12px; - border: 2px solid currentColor; - border-top-color: transparent; - border-radius: 50%; - animation: detecting-spin 0.8s linear infinite; - vertical-align: middle; - margin-right: 6px; -} - -@keyframes detecting-spin { - to { transform: rotate(360deg); } -} - -/* About Section */ -.about-info { - font-size: 13px; - color: var(--text-muted, #888); - line-height: 1.6; -} - -.about-info p { - margin: 0 0 12px 0; -} - -.about-info a { - color: var(--accent-cyan, #00d4ff); - text-decoration: none; -} - -.about-info a:hover { - text-decoration: underline; -} - -.about-version { - font-family: 'JetBrains Mono', monospace; - color: var(--accent-cyan, #00d4ff); -} - -/* Donate Button */ -.donate-btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 10px 20px; - background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%); - border: none; - border-radius: 6px; - color: #000; - font-size: 13px; - font-weight: 600; - text-decoration: none; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3); -} - -.donate-btn:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4); - filter: brightness(1.1); -} - -.donate-btn:active { - transform: translateY(0); -} - -/* Tile Provider Custom URL */ -.custom-url-row { - margin-top: 8px; - padding-top: 8px; -} - -.custom-url-row .settings-input { - width: 100%; -} - -/* Info Callout */ -.settings-info { - background: rgba(0, 212, 255, 0.1); - border: 1px solid rgba(0, 212, 255, 0.2); - border-radius: 6px; - padding: 12px; - margin-top: 16px; - font-size: 12px; - color: var(--text-muted, #888); -} - -.settings-info strong { - color: var(--accent-cyan, #00d4ff); -} - -/* Responsive */ -@media (max-width: 640px) { - .settings-modal.active { - padding: 20px 10px; - } - - .settings-content { - max-width: 100%; - } - - .settings-row { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .settings-select, - .settings-input { - width: 100%; - } -} +/* Settings Modal Styles */ + +.settings-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + z-index: 10000; + overflow-y: auto; + backdrop-filter: blur(4px); +} + +.settings-modal.active { + display: flex; + justify-content: center; + align-items: flex-start; + padding: 40px 20px; +} + +.settings-content { + background: var(--bg-dark, #0a0a0f); + border: 1px solid var(--border-color, #1a1a2e); + border-radius: 8px; + max-width: 600px; + width: 100%; + position: relative; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #1a1a2e); +} + +.settings-header h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + display: flex; + align-items: center; + gap: 8px; +} + +.settings-header h2 .icon { + width: 20px; + height: 20px; + color: var(--accent-cyan, #00d4ff); +} + +.settings-close { + background: none; + border: none; + color: var(--text-muted, #666); + font-size: 24px; + cursor: pointer; + padding: 4px; + line-height: 1; + transition: color 0.2s; +} + +.settings-close:hover { + color: var(--accent-red, #ff4444); +} + +/* Settings Tabs */ +.settings-tabs { + display: flex; + border-bottom: 1px solid var(--border-color, #1a1a2e); + padding: 0 20px; + gap: 4px; +} + +.settings-tab { + background: none; + border: none; + padding: 12px 16px; + color: var(--text-muted, #666); + font-size: 13px; + font-weight: 500; + cursor: pointer; + position: relative; + transition: color 0.2s; +} + +.settings-tab:hover { + color: var(--text-primary, #e0e0e0); +} + +.settings-tab.active { + color: var(--accent-cyan, #00d4ff); +} + +.settings-tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: var(--accent-cyan, #00d4ff); +} + +/* Settings Sections */ +.settings-section { + display: none; + padding: 20px; +} + +.settings-section.active { + display: block; +} + +.settings-group { + margin-bottom: 24px; +} + +.settings-group:last-child { + margin-bottom: 0; +} + +.settings-group-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted, #666); + margin-bottom: 12px; +} + +/* Settings Row */ +.settings-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.settings-row:last-child { + border-bottom: none; +} + +.settings-label { + display: flex; + flex-direction: column; + gap: 2px; +} + +.settings-label-text { + font-size: 13px; + color: var(--text-primary, #e0e0e0); +} + +.settings-label-desc { + font-size: 11px; + color: var(--text-muted, #666); +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + width: 44px; + height: 24px; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-tertiary, #1a1a2e); + border: 1px solid var(--border-color, #2a2a3e); + transition: 0.3s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 2px; + bottom: 2px; + background-color: var(--text-muted, #666); + transition: 0.3s; + border-radius: 50%; +} + +.toggle-switch input:checked + .toggle-slider { + background-color: var(--accent-cyan, #00d4ff); + border-color: var(--accent-cyan, #00d4ff); +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(20px); + background-color: white; +} + +.toggle-switch input:focus + .toggle-slider { + box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3); +} + +/* Select Dropdown */ +.settings-select { + background: var(--bg-tertiary, #1a1a2e); + border: 1px solid var(--border-color, #2a2a3e); + border-radius: 4px; + padding: 8px 12px; + font-size: 13px; + color: var(--text-primary, #e0e0e0); + min-width: 160px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 32px; +} + +.settings-select:focus { + outline: none; + border-color: var(--accent-cyan, #00d4ff); +} + +/* Text Input */ +.settings-input { + background: var(--bg-tertiary, #1a1a2e); + border: 1px solid var(--border-color, #2a2a3e); + border-radius: 4px; + padding: 8px 12px; + font-size: 13px; + color: var(--text-primary, #e0e0e0); + width: 200px; +} + +.settings-input:focus { + outline: none; + border-color: var(--accent-cyan, #00d4ff); +} + +.settings-input::placeholder { + color: var(--text-muted, #666); +} + +/* Asset Status */ +.asset-status { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; + padding: 12px; + background: var(--bg-secondary, #0f0f1a); + border-radius: 6px; +} + +.asset-status-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; +} + +.asset-name { + color: var(--text-muted, #888); +} + +.asset-badge { + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; +} + +.asset-badge.available { + background: rgba(0, 255, 136, 0.15); + color: var(--accent-green, #00ff88); +} + +.asset-badge.missing { + background: rgba(255, 68, 68, 0.15); + color: var(--accent-red, #ff4444); +} + +.asset-badge.checking { + background: rgba(255, 170, 0, 0.15); + color: var(--accent-orange, #ffaa00); +} + +/* Check Assets Button */ +.check-assets-btn { + background: var(--bg-tertiary, #1a1a2e); + border: 1px solid var(--border-color, #2a2a3e); + color: var(--text-primary, #e0e0e0); + padding: 8px 16px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + margin-top: 12px; + transition: all 0.2s; +} + +.check-assets-btn:hover { + border-color: var(--accent-cyan, #00d4ff); + color: var(--accent-cyan, #00d4ff); +} + +.check-assets-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* GPS Detection Spinner */ +.detecting-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: detecting-spin 0.8s linear infinite; + vertical-align: middle; + margin-right: 6px; +} + +@keyframes detecting-spin { + to { transform: rotate(360deg); } +} + +/* About Section */ +.about-info { + font-size: 13px; + color: var(--text-muted, #888); + line-height: 1.6; +} + +.about-info p { + margin: 0 0 12px 0; +} + +.about-info a { + color: var(--accent-cyan, #00d4ff); + text-decoration: none; +} + +.about-info a:hover { + text-decoration: underline; +} + +.about-version { + font-family: var(--font-mono); + color: var(--accent-cyan, #00d4ff); +} + +/* Donate Button */ +.donate-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 20px; + background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%); + border: none; + border-radius: 6px; + color: #000; + font-size: 13px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3); +} + +.donate-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4); + filter: brightness(1.1); +} + +.donate-btn:active { + transform: translateY(0); +} + +/* Tile Provider Custom URL */ +.custom-url-row { + margin-top: 8px; + padding-top: 8px; +} + +.custom-url-row .settings-input { + width: 100%; +} + +/* Info Callout */ +.settings-info { + background: rgba(0, 212, 255, 0.1); + border: 1px solid rgba(0, 212, 255, 0.2); + border-radius: 6px; + padding: 12px; + margin-top: 16px; + font-size: 12px; + color: var(--text-muted, #888); +} + +.settings-info strong { + color: var(--accent-cyan, #00d4ff); +} + +/* Responsive */ +@media (max-width: 640px) { + .settings-modal.active { + padding: 20px 10px; + } + + .settings-content { + max-width: 100%; + } + + .settings-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .settings-select, + .settings-input { + width: 100%; + } +} diff --git a/static/js/core/app.js b/static/js/core/app.js index 4cca0bd..bfab560 100644 --- a/static/js/core/app.js +++ b/static/js/core/app.js @@ -1,36 +1,36 @@ -/** - * Intercept - Core Application Logic - * Global state, mode switching, and shared functionality - */ - -// ============== GLOBAL STATE ============== - -// Mode state flags -let eventSource = null; -let isRunning = false; -let isSensorRunning = false; -let isAdsbRunning = false; -let isWifiRunning = false; -let isBtRunning = false; -let currentMode = 'pager'; - -// Message counters -let msgCount = 0; -let pocsagCount = 0; -let flexCount = 0; -let sensorCount = 0; -let filteredCount = 0; - -// Device list (populated from server via Jinja2) -let deviceList = []; - -// Auto-scroll setting -let autoScroll = localStorage.getItem('autoScroll') !== 'false'; - -// Mute setting -let muted = localStorage.getItem('audioMuted') === 'true'; - -// Observer location (load from localStorage or default to London) +/** + * Intercept - Core Application Logic + * Global state, mode switching, and shared functionality + */ + +// ============== GLOBAL STATE ============== + +// Mode state flags +let eventSource = null; +let isRunning = false; +let isSensorRunning = false; +let isAdsbRunning = false; +let isWifiRunning = false; +let isBtRunning = false; +let currentMode = 'pager'; + +// Message counters +let msgCount = 0; +let pocsagCount = 0; +let flexCount = 0; +let sensorCount = 0; +let filteredCount = 0; + +// Device list (populated from server via Jinja2) +let deviceList = []; + +// Auto-scroll setting +let autoScroll = localStorage.getItem('autoScroll') !== 'false'; + +// Mute setting +let muted = localStorage.getItem('audioMuted') === 'true'; + +// Observer location (load from localStorage or default to London) let observerLocation = (function() { if (window.ObserverLocation && ObserverLocation.getForModule) { return ObserverLocation.getForModule('observerLocation'); @@ -44,464 +44,464 @@ let observerLocation = (function() { } return { lat: 51.5074, lon: -0.1278 }; })(); - -// Message storage for export -let allMessages = []; - -// Track unique sensor devices -let uniqueDevices = new Set(); - -// SDR device usage tracking -let sdrDeviceUsage = {}; - -// ============== DISCLAIMER HANDLING ============== - -function checkDisclaimer() { - const accepted = localStorage.getItem('disclaimerAccepted'); - if (accepted === 'true') { - document.getElementById('disclaimerModal').classList.add('disclaimer-hidden'); - } -} - -function acceptDisclaimer() { - localStorage.setItem('disclaimerAccepted', 'true'); - document.getElementById('disclaimerModal').classList.add('disclaimer-hidden'); -} - -function declineDisclaimer() { - document.getElementById('disclaimerModal').classList.add('disclaimer-hidden'); - document.getElementById('rejectionPage').classList.remove('disclaimer-hidden'); -} - -// ============== HEADER CLOCK ============== - -function updateHeaderClock() { - const now = new Date(); - const utc = now.toISOString().substring(11, 19); - document.getElementById('headerUtcTime').textContent = utc; -} - -// ============== MODE SWITCHING ============== - -function switchMode(mode) { - // Stop any running scans when switching modes - if (isRunning && typeof stopDecoding === 'function') stopDecoding(); - if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding(); - if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan(); - if (isBtRunning && typeof stopBtScan === 'function') stopBtScan(); - if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan(); - - currentMode = mode; - - // Remove active from all nav buttons, then add to the correct one - document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active')); - const modeMap = { - 'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft', - 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', - 'listening': 'listening', 'meshtastic': 'meshtastic' - }; - document.querySelectorAll('.mode-nav-btn').forEach(btn => { - const label = btn.querySelector('.nav-label'); - if (label && label.textContent.toLowerCase().includes(modeMap[mode])) { - btn.classList.add('active'); - } - }); - - // Toggle mode content visibility - document.getElementById('pagerMode').classList.toggle('active', mode === 'pager'); - document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor'); - document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft'); - document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite'); - document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi'); - document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth'); - document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening'); - document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs'); - document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm'); - document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr'); - document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations'); - document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic'); - - // Toggle stats visibility - document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none'; - document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none'; - document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none'; - document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none'; - document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none'; - - // Hide signal meter - individual panels show signal strength where needed - document.getElementById('signalMeter').style.display = 'none'; - - // Show/hide dashboard buttons in nav bar - document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none'; - document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none'; - - // Update active mode indicator - const modeNames = { - 'pager': 'PAGER', - 'sensor': '433MHZ', - 'aircraft': 'AIRCRAFT', - 'satellite': 'SATELLITE', - 'wifi': 'WIFI', - 'bluetooth': 'BLUETOOTH', - 'listening': 'LISTENING POST', - 'tscm': 'TSCM', - 'aprs': 'APRS', - 'meshtastic': 'MESHTASTIC' - }; - document.getElementById('activeModeIndicator').innerHTML = '' + modeNames[mode]; - - // Update mobile nav buttons - updateMobileNavButtons(mode); - - // Close mobile drawer when mode is switched (on mobile) - if (window.innerWidth < 1024 && typeof window.closeMobileDrawer === 'function') { - window.closeMobileDrawer(); - } - - // Toggle layout containers - document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none'; - document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none'; - - // Respect the "Show Radar Display" checkbox for aircraft mode - const showRadar = document.getElementById('adsbEnableMap')?.checked; - document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none'; - document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none'; - document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none'; - - // Update output panel title based on mode - const titles = { - 'pager': 'Pager Decoder', - 'sensor': '433MHz Sensor Monitor', - 'aircraft': 'ADS-B Aircraft Tracker', - 'satellite': 'Satellite Monitor', - 'wifi': 'WiFi Scanner', - 'bluetooth': 'Bluetooth Scanner', - 'listening': 'Listening Post', - 'meshtastic': 'Meshtastic Mesh Monitor' - }; - document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor'; - - // Show/hide Device Intelligence for modes that use it - const reconBtn = document.getElementById('reconBtn'); - const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); - if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') { - document.getElementById('reconPanel').style.display = 'none'; - if (reconBtn) reconBtn.style.display = 'none'; - if (intelBtn) intelBtn.style.display = 'none'; - } else { - if (reconBtn) reconBtn.style.display = 'inline-block'; - if (intelBtn) intelBtn.style.display = 'inline-block'; - if (typeof reconEnabled !== 'undefined' && reconEnabled) { - document.getElementById('reconPanel').style.display = 'block'; - } - } - - // Show RTL-SDR device section for modes that use it - document.getElementById('rtlDeviceSection').style.display = - (mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none'; - - // Toggle mode-specific tool status displays - document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none'; - document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none'; - document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none'; - - // Hide waterfall and output console for modes with their own visualizations - document.querySelector('.waterfall-container').style.display = - (mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block'; - document.getElementById('output').style.display = - (mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block'; - document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex'; - - // Load interfaces and initialize visualizations when switching modes - if (mode === 'wifi') { - if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces(); - if (typeof initRadar === 'function') initRadar(); - if (typeof initWatchList === 'function') initWatchList(); - } else if (mode === 'bluetooth') { - if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces(); - if (typeof initBtRadar === 'function') initBtRadar(); - } else if (mode === 'aircraft') { - if (typeof checkAdsbTools === 'function') checkAdsbTools(); - if (typeof initAircraftRadar === 'function') initAircraftRadar(); - } else if (mode === 'satellite') { - if (typeof initPolarPlot === 'function') initPolarPlot(); - if (typeof initSatelliteList === 'function') initSatelliteList(); - } else if (mode === 'listening') { - if (typeof checkScannerTools === 'function') checkScannerTools(); - if (typeof checkAudioTools === 'function') checkAudioTools(); - if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect(); - if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect(); - } else if (mode === 'meshtastic') { - if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init(); - } -} - -// ============== SECTION COLLAPSE ============== - -function toggleSection(el) { - el.closest('.section').classList.toggle('collapsed'); -} - -// ============== THEME MANAGEMENT ============== - -function toggleTheme() { - const html = document.documentElement; - const currentTheme = html.getAttribute('data-theme'); - const newTheme = currentTheme === 'light' ? 'dark' : 'light'; - html.setAttribute('data-theme', newTheme); - localStorage.setItem('theme', newTheme); - - // Update button text - const btn = document.getElementById('themeToggle'); - if (btn) { - btn.textContent = newTheme === 'light' ? '🌙' : '☀️'; - } -} - -function loadTheme() { - const savedTheme = localStorage.getItem('theme') || 'dark'; - document.documentElement.setAttribute('data-theme', savedTheme); - const btn = document.getElementById('themeToggle'); - if (btn) { - btn.textContent = savedTheme === 'light' ? '🌙' : '☀️'; - } -} - -// ============== AUTO-SCROLL ============== - -function toggleAutoScroll() { - autoScroll = !autoScroll; - localStorage.setItem('autoScroll', autoScroll); - updateAutoScrollButton(); -} - -function updateAutoScrollButton() { - const btn = document.getElementById('autoScrollBtn'); - if (btn) { - btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF'; - btn.classList.toggle('active', autoScroll); - } -} - -// ============== SDR DEVICE MANAGEMENT ============== - -function getSelectedDevice() { - return document.getElementById('deviceSelect').value; -} - -function getSelectedSDRType() { - return document.getElementById('sdrTypeSelect').value; -} - -function reserveDevice(deviceIndex, modeId) { - sdrDeviceUsage[modeId] = deviceIndex; -} - -function releaseDevice(modeId) { - delete sdrDeviceUsage[modeId]; -} - -function checkDeviceAvailability(requestingMode) { - const selectedDevice = parseInt(getSelectedDevice()); - for (const [mode, device] of Object.entries(sdrDeviceUsage)) { - if (mode !== requestingMode && device === selectedDevice) { - alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`); - return false; - } - } - return true; -} - -// ============== BIAS-T SETTINGS ============== - -function saveBiasTSetting() { - const enabled = document.getElementById('biasT')?.checked || false; - localStorage.setItem('biasTEnabled', enabled); -} - -function getBiasTEnabled() { - return document.getElementById('biasT')?.checked || false; -} - -function loadBiasTSetting() { - const saved = localStorage.getItem('biasTEnabled'); - if (saved === 'true') { - const checkbox = document.getElementById('biasT'); - if (checkbox) checkbox.checked = true; - } -} - -// ============== REMOTE SDR ============== - -function toggleRemoteSDR() { - const useRemote = document.getElementById('useRemoteSDR').checked; - const configDiv = document.getElementById('remoteSDRConfig'); - const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect'); - - if (useRemote) { - configDiv.style.display = 'block'; - localControls.forEach(el => el.disabled = true); - } else { - configDiv.style.display = 'none'; - localControls.forEach(el => el.disabled = false); - } -} - -function getRemoteSDRConfig() { - const useRemote = document.getElementById('useRemoteSDR')?.checked; - if (!useRemote) return null; - - const host = document.getElementById('rtlTcpHost')?.value || 'localhost'; - const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234'); - - if (!host || isNaN(port)) { - alert('Please enter valid rtl_tcp host and port'); - return false; - } - - return { host, port }; -} - -// ============== OUTPUT DISPLAY ============== - -function showInfo(text) { - const output = document.getElementById('output'); - if (!output) return; - - const placeholder = output.querySelector('.placeholder'); - if (placeholder) placeholder.remove(); - - const infoEl = document.createElement('div'); - infoEl.className = 'info-msg'; - infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;'; - infoEl.textContent = text; - output.insertBefore(infoEl, output.firstChild); -} - -function showError(text) { - const output = document.getElementById('output'); - if (!output) return; - - const placeholder = output.querySelector('.placeholder'); - if (placeholder) placeholder.remove(); - - const errorEl = document.createElement('div'); - errorEl.className = 'error-msg'; - errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;'; - errorEl.textContent = '⚠ ' + text; - output.insertBefore(errorEl, output.firstChild); -} - -// ============== INITIALIZATION ============== - -// ============== MOBILE NAVIGATION ============== - -function initMobileNav() { - const hamburgerBtn = document.getElementById('hamburgerBtn'); - const sidebar = document.getElementById('mainSidebar'); - const overlay = document.getElementById('drawerOverlay'); - - if (!hamburgerBtn || !sidebar || !overlay) return; - - function openDrawer() { - sidebar.classList.add('open'); - overlay.classList.add('visible'); - hamburgerBtn.classList.add('active'); - document.body.style.overflow = 'hidden'; - } - - function closeDrawer() { - sidebar.classList.remove('open'); - overlay.classList.remove('visible'); - hamburgerBtn.classList.remove('active'); - document.body.style.overflow = ''; - } - - function toggleDrawer() { - if (sidebar.classList.contains('open')) { - closeDrawer(); - } else { - openDrawer(); - } - } - - hamburgerBtn.addEventListener('click', toggleDrawer); - overlay.addEventListener('click', closeDrawer); - - // Close drawer when resizing to desktop - window.addEventListener('resize', () => { - if (window.innerWidth >= 1024) { - closeDrawer(); - } - }); - - // Expose for external use - window.toggleMobileDrawer = toggleDrawer; - window.closeMobileDrawer = closeDrawer; -} - -function setViewportHeight() { - // Fix for iOS Safari address bar height - const vh = window.innerHeight * 0.01; - document.documentElement.style.setProperty('--vh', `${vh}px`); -} - -function updateMobileNavButtons(mode) { - // Update mobile nav bar buttons - document.querySelectorAll('.mobile-nav-btn').forEach(btn => { - const btnMode = btn.getAttribute('data-mode'); - btn.classList.toggle('active', btnMode === mode); - }); -} - -function initApp() { - // Check disclaimer - checkDisclaimer(); - - // Load theme - loadTheme(); - - // Start clock - updateHeaderClock(); - setInterval(updateHeaderClock, 1000); - - // Load bias-T setting - loadBiasTSetting(); - - // Initialize observer location inputs - const adsbLatInput = document.getElementById('adsbObsLat'); - const adsbLonInput = document.getElementById('adsbObsLon'); - const obsLatInput = document.getElementById('obsLat'); - const obsLonInput = document.getElementById('obsLon'); - if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4); - if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4); - if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4); - if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4); - - // Update UI state - updateAutoScrollButton(); - - // Make sections collapsible - document.querySelectorAll('.section h3').forEach(h3 => { - h3.addEventListener('click', function() { - this.parentElement.classList.toggle('collapsed'); - }); - }); - - // Collapse all sections by default (except SDR Device which is first) - document.querySelectorAll('.section').forEach((section, index) => { - if (index > 0) { - section.classList.add('collapsed'); - } - }); - - // Initialize mobile navigation - initMobileNav(); - - // Set viewport height for mobile browsers - setViewportHeight(); - window.addEventListener('resize', setViewportHeight); -} - -// Run initialization when DOM is ready -document.addEventListener('DOMContentLoaded', initApp); + +// Message storage for export +let allMessages = []; + +// Track unique sensor devices +let uniqueDevices = new Set(); + +// SDR device usage tracking +let sdrDeviceUsage = {}; + +// ============== DISCLAIMER HANDLING ============== + +function checkDisclaimer() { + const accepted = localStorage.getItem('disclaimerAccepted'); + if (accepted === 'true') { + document.getElementById('disclaimerModal').classList.add('disclaimer-hidden'); + } +} + +function acceptDisclaimer() { + localStorage.setItem('disclaimerAccepted', 'true'); + document.getElementById('disclaimerModal').classList.add('disclaimer-hidden'); +} + +function declineDisclaimer() { + document.getElementById('disclaimerModal').classList.add('disclaimer-hidden'); + document.getElementById('rejectionPage').classList.remove('disclaimer-hidden'); +} + +// ============== HEADER CLOCK ============== + +function updateHeaderClock() { + const now = new Date(); + const utc = now.toISOString().substring(11, 19); + document.getElementById('headerUtcTime').textContent = utc; +} + +// ============== MODE SWITCHING ============== + +function switchMode(mode) { + // Stop any running scans when switching modes + if (isRunning && typeof stopDecoding === 'function') stopDecoding(); + if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding(); + if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan(); + if (isBtRunning && typeof stopBtScan === 'function') stopBtScan(); + if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan(); + + currentMode = mode; + + // Remove active from all nav buttons, then add to the correct one + document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active')); + const modeMap = { + 'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft', + 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', + 'listening': 'listening', 'meshtastic': 'meshtastic' + }; + document.querySelectorAll('.mode-nav-btn').forEach(btn => { + const label = btn.querySelector('.nav-label'); + if (label && label.textContent.toLowerCase().includes(modeMap[mode])) { + btn.classList.add('active'); + } + }); + + // Toggle mode content visibility + document.getElementById('pagerMode').classList.toggle('active', mode === 'pager'); + document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor'); + document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft'); + document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite'); + document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi'); + document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth'); + document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening'); + document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs'); + document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm'); + document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr'); + document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations'); + document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic'); + + // Toggle stats visibility + document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none'; + document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none'; + document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none'; + document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none'; + document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none'; + + // Hide signal meter - individual panels show signal strength where needed + document.getElementById('signalMeter').style.display = 'none'; + + // Show/hide dashboard buttons in nav bar + document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none'; + document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none'; + + // Update active mode indicator + const modeNames = { + 'pager': 'PAGER', + 'sensor': '433MHZ', + 'aircraft': 'AIRCRAFT', + 'satellite': 'SATELLITE', + 'wifi': 'WIFI', + 'bluetooth': 'BLUETOOTH', + 'listening': 'LISTENING POST', + 'tscm': 'TSCM', + 'aprs': 'APRS', + 'meshtastic': 'MESHTASTIC' + }; + document.getElementById('activeModeIndicator').innerHTML = '' + modeNames[mode]; + + // Update mobile nav buttons + updateMobileNavButtons(mode); + + // Close mobile drawer when mode is switched (on mobile) + if (window.innerWidth < 1024 && typeof window.closeMobileDrawer === 'function') { + window.closeMobileDrawer(); + } + + // Toggle layout containers + document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none'; + document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none'; + + // Respect the "Show Radar Display" checkbox for aircraft mode + const showRadar = document.getElementById('adsbEnableMap')?.checked; + document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none'; + document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none'; + document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none'; + + // Update output panel title based on mode + const titles = { + 'pager': 'Pager Decoder', + 'sensor': '433MHz Sensor Monitor', + 'aircraft': 'ADS-B Aircraft Tracker', + 'satellite': 'Satellite Monitor', + 'wifi': 'WiFi Scanner', + 'bluetooth': 'Bluetooth Scanner', + 'listening': 'Listening Post', + 'meshtastic': 'Meshtastic Mesh Monitor' + }; + document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor'; + + // Show/hide Device Intelligence for modes that use it + const reconBtn = document.getElementById('reconBtn'); + const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); + if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') { + document.getElementById('reconPanel').style.display = 'none'; + if (reconBtn) reconBtn.style.display = 'none'; + if (intelBtn) intelBtn.style.display = 'none'; + } else { + if (reconBtn) reconBtn.style.display = 'inline-block'; + if (intelBtn) intelBtn.style.display = 'inline-block'; + if (typeof reconEnabled !== 'undefined' && reconEnabled) { + document.getElementById('reconPanel').style.display = 'block'; + } + } + + // Show RTL-SDR device section for modes that use it + document.getElementById('rtlDeviceSection').style.display = + (mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none'; + + // Toggle mode-specific tool status displays + document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none'; + document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none'; + document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none'; + + // Hide waterfall and output console for modes with their own visualizations + document.querySelector('.waterfall-container').style.display = + (mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block'; + document.getElementById('output').style.display = + (mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block'; + document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex'; + + // Load interfaces and initialize visualizations when switching modes + if (mode === 'wifi') { + if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces(); + if (typeof initRadar === 'function') initRadar(); + if (typeof initWatchList === 'function') initWatchList(); + } else if (mode === 'bluetooth') { + if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces(); + if (typeof initBtRadar === 'function') initBtRadar(); + } else if (mode === 'aircraft') { + if (typeof checkAdsbTools === 'function') checkAdsbTools(); + if (typeof initAircraftRadar === 'function') initAircraftRadar(); + } else if (mode === 'satellite') { + if (typeof initPolarPlot === 'function') initPolarPlot(); + if (typeof initSatelliteList === 'function') initSatelliteList(); + } else if (mode === 'listening') { + if (typeof checkScannerTools === 'function') checkScannerTools(); + if (typeof checkAudioTools === 'function') checkAudioTools(); + if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect(); + if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect(); + } else if (mode === 'meshtastic') { + if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init(); + } +} + +// ============== SECTION COLLAPSE ============== + +function toggleSection(el) { + el.closest('.section').classList.toggle('collapsed'); +} + +// ============== THEME MANAGEMENT ============== + +function toggleTheme() { + const html = document.documentElement; + const currentTheme = html.getAttribute('data-theme'); + const newTheme = currentTheme === 'light' ? 'dark' : 'light'; + html.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + + // Update button text + const btn = document.getElementById('themeToggle'); + if (btn) { + btn.textContent = newTheme === 'light' ? '🌙' : '☀️'; + } +} + +function loadTheme() { + const savedTheme = localStorage.getItem('theme') || 'dark'; + document.documentElement.setAttribute('data-theme', savedTheme); + const btn = document.getElementById('themeToggle'); + if (btn) { + btn.textContent = savedTheme === 'light' ? '🌙' : '☀️'; + } +} + +// ============== AUTO-SCROLL ============== + +function toggleAutoScroll() { + autoScroll = !autoScroll; + localStorage.setItem('autoScroll', autoScroll); + updateAutoScrollButton(); +} + +function updateAutoScrollButton() { + const btn = document.getElementById('autoScrollBtn'); + if (btn) { + btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF'; + btn.classList.toggle('active', autoScroll); + } +} + +// ============== SDR DEVICE MANAGEMENT ============== + +function getSelectedDevice() { + return document.getElementById('deviceSelect').value; +} + +function getSelectedSDRType() { + return document.getElementById('sdrTypeSelect').value; +} + +function reserveDevice(deviceIndex, modeId) { + sdrDeviceUsage[modeId] = deviceIndex; +} + +function releaseDevice(modeId) { + delete sdrDeviceUsage[modeId]; +} + +function checkDeviceAvailability(requestingMode) { + const selectedDevice = parseInt(getSelectedDevice()); + for (const [mode, device] of Object.entries(sdrDeviceUsage)) { + if (mode !== requestingMode && device === selectedDevice) { + alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`); + return false; + } + } + return true; +} + +// ============== BIAS-T SETTINGS ============== + +function saveBiasTSetting() { + const enabled = document.getElementById('biasT')?.checked || false; + localStorage.setItem('biasTEnabled', enabled); +} + +function getBiasTEnabled() { + return document.getElementById('biasT')?.checked || false; +} + +function loadBiasTSetting() { + const saved = localStorage.getItem('biasTEnabled'); + if (saved === 'true') { + const checkbox = document.getElementById('biasT'); + if (checkbox) checkbox.checked = true; + } +} + +// ============== REMOTE SDR ============== + +function toggleRemoteSDR() { + const useRemote = document.getElementById('useRemoteSDR').checked; + const configDiv = document.getElementById('remoteSDRConfig'); + const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect'); + + if (useRemote) { + configDiv.style.display = 'block'; + localControls.forEach(el => el.disabled = true); + } else { + configDiv.style.display = 'none'; + localControls.forEach(el => el.disabled = false); + } +} + +function getRemoteSDRConfig() { + const useRemote = document.getElementById('useRemoteSDR')?.checked; + if (!useRemote) return null; + + const host = document.getElementById('rtlTcpHost')?.value || 'localhost'; + const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234'); + + if (!host || isNaN(port)) { + alert('Please enter valid rtl_tcp host and port'); + return false; + } + + return { host, port }; +} + +// ============== OUTPUT DISPLAY ============== + +function showInfo(text) { + const output = document.getElementById('output'); + if (!output) return; + + const placeholder = output.querySelector('.placeholder'); + if (placeholder) placeholder.remove(); + + const infoEl = document.createElement('div'); + infoEl.className = 'info-msg'; + infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Terminus", monospace; font-size: 11px; color: #888; word-break: break-all;'; + infoEl.textContent = text; + output.insertBefore(infoEl, output.firstChild); +} + +function showError(text) { + const output = document.getElementById('output'); + if (!output) return; + + const placeholder = output.querySelector('.placeholder'); + if (placeholder) placeholder.remove(); + + const errorEl = document.createElement('div'); + errorEl.className = 'error-msg'; + errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Terminus", monospace; font-size: 11px; color: #ff6688; word-break: break-all;'; + errorEl.textContent = '⚠ ' + text; + output.insertBefore(errorEl, output.firstChild); +} + +// ============== INITIALIZATION ============== + +// ============== MOBILE NAVIGATION ============== + +function initMobileNav() { + const hamburgerBtn = document.getElementById('hamburgerBtn'); + const sidebar = document.getElementById('mainSidebar'); + const overlay = document.getElementById('drawerOverlay'); + + if (!hamburgerBtn || !sidebar || !overlay) return; + + function openDrawer() { + sidebar.classList.add('open'); + overlay.classList.add('visible'); + hamburgerBtn.classList.add('active'); + document.body.style.overflow = 'hidden'; + } + + function closeDrawer() { + sidebar.classList.remove('open'); + overlay.classList.remove('visible'); + hamburgerBtn.classList.remove('active'); + document.body.style.overflow = ''; + } + + function toggleDrawer() { + if (sidebar.classList.contains('open')) { + closeDrawer(); + } else { + openDrawer(); + } + } + + hamburgerBtn.addEventListener('click', toggleDrawer); + overlay.addEventListener('click', closeDrawer); + + // Close drawer when resizing to desktop + window.addEventListener('resize', () => { + if (window.innerWidth >= 1024) { + closeDrawer(); + } + }); + + // Expose for external use + window.toggleMobileDrawer = toggleDrawer; + window.closeMobileDrawer = closeDrawer; +} + +function setViewportHeight() { + // Fix for iOS Safari address bar height + const vh = window.innerHeight * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); +} + +function updateMobileNavButtons(mode) { + // Update mobile nav bar buttons + document.querySelectorAll('.mobile-nav-btn').forEach(btn => { + const btnMode = btn.getAttribute('data-mode'); + btn.classList.toggle('active', btnMode === mode); + }); +} + +function initApp() { + // Check disclaimer + checkDisclaimer(); + + // Load theme + loadTheme(); + + // Start clock + updateHeaderClock(); + setInterval(updateHeaderClock, 1000); + + // Load bias-T setting + loadBiasTSetting(); + + // Initialize observer location inputs + const adsbLatInput = document.getElementById('adsbObsLat'); + const adsbLonInput = document.getElementById('adsbObsLon'); + const obsLatInput = document.getElementById('obsLat'); + const obsLonInput = document.getElementById('obsLon'); + if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4); + if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4); + if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4); + if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4); + + // Update UI state + updateAutoScrollButton(); + + // Make sections collapsible + document.querySelectorAll('.section h3').forEach(h3 => { + h3.addEventListener('click', function() { + this.parentElement.classList.toggle('collapsed'); + }); + }); + + // Collapse all sections by default (except SDR Device which is first) + document.querySelectorAll('.section').forEach((section, index) => { + if (index > 0) { + section.classList.add('collapsed'); + } + }); + + // Initialize mobile navigation + initMobileNav(); + + // Set viewport height for mobile browsers + setViewportHeight(); + window.addEventListener('resize', setViewportHeight); +} + +// Run initialization when DOM is ready +document.addEventListener('DOMContentLoaded', initApp); diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index 7e3fbad..8282e1b 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -1,906 +1,906 @@ -/** - * Settings Manager - Handles offline mode and application settings - */ - -const Settings = { - // Default settings - defaults: { - 'offline.enabled': false, - 'offline.assets_source': 'cdn', - 'offline.fonts_source': 'cdn', - 'offline.tile_provider': 'cartodb_dark', - 'offline.tile_server_url': '' - }, - - // Tile provider configurations - tileProviders: { - openstreetmap: { - url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - attribution: '© OpenStreetMap contributors', - subdomains: 'abc' - }, - cartodb_dark: { - url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', - attribution: '© OSM © CARTO', - subdomains: 'abcd' - }, - cartodb_light: { - url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', - attribution: '© OSM © CARTO', - subdomains: 'abcd' - }, - esri_world: { - url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community', - subdomains: null - } - }, - - // Registry of maps that can be updated - _registeredMaps: [], - - // Current settings cache - _cache: {}, - - /** - * Initialize settings - load from server/localStorage - */ - async init() { - try { - const response = await fetch('/offline/settings'); - if (response.ok) { - const data = await response.json(); - this._cache = { ...this.defaults, ...data.settings }; - } else { - // Fall back to localStorage - this._loadFromLocalStorage(); - } - } catch (e) { - console.warn('Failed to load settings from server, using localStorage:', e); - this._loadFromLocalStorage(); - } - - this._updateUI(); - return this._cache; - }, - - /** - * Load settings from localStorage - */ - _loadFromLocalStorage() { - const stored = localStorage.getItem('intercept_settings'); - if (stored) { - try { - this._cache = { ...this.defaults, ...JSON.parse(stored) }; - } catch (e) { - this._cache = { ...this.defaults }; - } - } else { - this._cache = { ...this.defaults }; - } - }, - - /** - * Save a setting to server and localStorage - */ - async _save(key, value) { - this._cache[key] = value; - - // Save to localStorage as backup - localStorage.setItem('intercept_settings', JSON.stringify(this._cache)); - - // Save to server - try { - await fetch('/offline/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, value }) - }); - } catch (e) { - console.warn('Failed to save setting to server:', e); - } - }, - - /** - * Get a setting value - */ - get(key) { - return this._cache[key] ?? this.defaults[key]; - }, - - /** - * Toggle offline mode master switch - */ - async toggleOfflineMode(enabled) { - await this._save('offline.enabled', enabled); - - if (enabled) { - // When enabling offline mode, also switch assets and fonts to local - await this._save('offline.assets_source', 'local'); - await this._save('offline.fonts_source', 'local'); - } - - this._updateUI(); - this._showReloadPrompt(); - }, - - /** - * Set asset source (cdn or local) - */ - async setAssetSource(source) { - await this._save('offline.assets_source', source); - this._showReloadPrompt(); - }, - - /** - * Set fonts source (cdn or local) - */ - async setFontsSource(source) { - await this._save('offline.fonts_source', source); - this._showReloadPrompt(); - }, - - /** - * Set tile provider - */ - async setTileProvider(provider) { - await this._save('offline.tile_provider', provider); - - // Show/hide custom URL input - const customRow = document.getElementById('customTileUrlRow'); - if (customRow) { - customRow.style.display = provider === 'custom' ? 'block' : 'none'; - } - - // If not custom and we have a map, update tiles immediately - if (provider !== 'custom') { - this._updateMapTiles(); - } - }, - - /** - * Set custom tile server URL - */ - async setCustomTileUrl(url) { - await this._save('offline.tile_server_url', url); - this._updateMapTiles(); - }, - - /** - * Get current tile configuration - */ - getTileConfig() { - const provider = this.get('offline.tile_provider'); - - if (provider === 'custom') { - const customUrl = this.get('offline.tile_server_url'); - return { - url: customUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - attribution: 'Custom Tile Server', - subdomains: 'abc' - }; - } - - return this.tileProviders[provider] || this.tileProviders.cartodb_dark; - }, - - /** - * Register a map to receive tile updates when settings change - * @param {L.Map} map - Leaflet map instance - */ - registerMap(map) { - if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) { - this._registeredMaps.push(map); - } - }, - - /** - * Unregister a map - * @param {L.Map} map - Leaflet map instance - */ - unregisterMap(map) { - const idx = this._registeredMaps.indexOf(map); - if (idx > -1) { - this._registeredMaps.splice(idx, 1); - } - }, - - /** - * Create a tile layer using current settings - * @returns {L.TileLayer} Configured tile layer - */ - createTileLayer() { - const config = this.getTileConfig(); - const options = { - attribution: config.attribution, - maxZoom: 19 - }; - if (config.subdomains) { - options.subdomains = config.subdomains; - } - return L.tileLayer(config.url, options); - }, - - /** - * Check if local assets are available - */ - async checkAssets() { - const assets = { - leaflet: [ - '/static/vendor/leaflet/leaflet.js', - '/static/vendor/leaflet/leaflet.css' - ], - chartjs: [ - '/static/vendor/chartjs/chart.umd.min.js' - ], - inter: [ - '/static/vendor/fonts/Inter-Regular.woff2' - ], - jetbrains: [ - '/static/vendor/fonts/JetBrainsMono-Regular.woff2' - ] - }; - - const results = {}; - - for (const [name, urls] of Object.entries(assets)) { - const statusEl = document.getElementById(`status${name.charAt(0).toUpperCase() + name.slice(1)}`); - if (statusEl) { - statusEl.textContent = 'Checking...'; - statusEl.className = 'asset-badge checking'; - } - - let available = true; - for (const url of urls) { - try { - const response = await fetch(url, { method: 'HEAD' }); - if (!response.ok) { - available = false; - break; - } - } catch (e) { - available = false; - break; - } - } - - results[name] = available; - - if (statusEl) { - statusEl.textContent = available ? 'Available' : 'Missing'; - statusEl.className = `asset-badge ${available ? 'available' : 'missing'}`; - } - } - - return results; - }, - - /** - * Update UI elements to reflect current settings - */ - _updateUI() { - // Offline mode toggle - const offlineEnabled = document.getElementById('offlineEnabled'); - if (offlineEnabled) { - offlineEnabled.checked = this.get('offline.enabled'); - } - - // Assets source - const assetsSource = document.getElementById('assetsSource'); - if (assetsSource) { - assetsSource.value = this.get('offline.assets_source'); - } - - // Fonts source - const fontsSource = document.getElementById('fontsSource'); - if (fontsSource) { - fontsSource.value = this.get('offline.fonts_source'); - } - - // Tile provider - const tileProvider = document.getElementById('tileProvider'); - if (tileProvider) { - tileProvider.value = this.get('offline.tile_provider'); - } - - // Custom tile URL - const customTileUrl = document.getElementById('customTileUrl'); - if (customTileUrl) { - customTileUrl.value = this.get('offline.tile_server_url') || ''; - } - - // Show/hide custom URL row - const customRow = document.getElementById('customTileUrlRow'); - if (customRow) { - customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none'; - } - }, - - /** - * Update map tiles on all known maps - */ - _updateMapTiles() { - // Combine registered maps with common window map variables - const windowMaps = [ - window.map, - window.leafletMap, - window.aprsMap, - window.adsbMap, - window.radarMap, - window.vesselMap, - window.groundMap, - window.groundTrackMap, - window.meshMap - ].filter(m => m && typeof m.eachLayer === 'function'); - - // Combine with registered maps, removing duplicates - const allMaps = [...new Set([...this._registeredMaps, ...windowMaps])]; - - if (allMaps.length === 0) return; - - const config = this.getTileConfig(); - - allMaps.forEach(map => { - // Remove existing tile layers - map.eachLayer(layer => { - if (layer instanceof L.TileLayer) { - map.removeLayer(layer); - } - }); - - // Add new tile layer - const options = { - attribution: config.attribution, - maxZoom: 19 - }; - if (config.subdomains) { - options.subdomains = config.subdomains; - } - - L.tileLayer(config.url, options).addTo(map); - }); - }, - - /** - * Show reload prompt - */ - _showReloadPrompt() { - // Create or update reload prompt - let prompt = document.getElementById('settingsReloadPrompt'); - if (!prompt) { - prompt = document.createElement('div'); - prompt.id = 'settingsReloadPrompt'; - prompt.style.cssText = ` - position: fixed; - bottom: 20px; - right: 20px; - background: var(--bg-dark, #0a0a0f); - border: 1px solid var(--accent-cyan, #00d4ff); - border-radius: 8px; - padding: 12px 16px; - display: flex; - align-items: center; - gap: 12px; - z-index: 10001; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - `; - prompt.innerHTML = ` - - Reload to apply changes - - - - `; - document.body.appendChild(prompt); - } - } -}; - -// Settings modal functions -function showSettings() { - const modal = document.getElementById('settingsModal'); - if (modal) { - modal.classList.add('active'); - Settings.init().then(() => { - Settings.checkAssets(); - }); - } -} - -function hideSettings() { - const modal = document.getElementById('settingsModal'); - if (modal) { - modal.classList.remove('active'); - } -} - -function switchSettingsTab(tabName) { - // Update tab buttons - document.querySelectorAll('.settings-tab').forEach(tab => { - tab.classList.toggle('active', tab.dataset.tab === tabName); - }); - - // Update sections - document.querySelectorAll('.settings-section').forEach(section => { - section.classList.toggle('active', section.id === `settings-${tabName}`); - }); - - // Load tools/dependencies when that tab is selected - if (tabName === 'tools') { - loadSettingsTools(); - } -} - -/** - * Load tool dependencies into settings modal - */ -function loadSettingsTools() { - const content = document.getElementById('settingsToolsContent'); - if (!content) return; - - content.innerHTML = '
Loading dependencies...
'; - - fetch('/dependencies') - .then(r => r.json()) - .then(data => { - if (data.status !== 'success') { - content.innerHTML = '
Error loading dependencies
'; - return; - } - - let html = ''; - let totalMissing = 0; - - for (const [modeKey, mode] of Object.entries(data.modes)) { - const statusColor = mode.ready ? 'var(--accent-green)' : 'var(--accent-red)'; - const statusIcon = mode.ready ? '✓' : '✗'; - - html += ` -
-
- ${mode.name} - ${statusIcon} ${mode.ready ? 'Ready' : 'Missing'} -
-
- `; - - for (const [toolName, tool] of Object.entries(mode.tools)) { - const installed = tool.installed; - const dotColor = installed ? 'var(--accent-green)' : 'var(--accent-red)'; - const requiredBadge = tool.required ? 'REQ' : ''; - - if (!installed) totalMissing++; - - let installCmd = ''; - if (tool.install) { - if (tool.install.pip) { - installCmd = tool.install.pip; - } else if (data.pkg_manager && tool.install[data.pkg_manager]) { - installCmd = tool.install[data.pkg_manager]; - } else if (tool.install.manual) { - installCmd = tool.install.manual; - } - } - - html += ` -
- -
- ${toolName}${requiredBadge} -
${tool.description}
-
- ${!installed && installCmd ? ` - ${installCmd} - ` : ''} - ${installed ? 'OK' : 'MISSING'} -
- `; - } - - html += '
'; - } - - // Summary at top - const summaryHtml = ` -
-
- ${totalMissing > 0 ? '⚠️ ' + totalMissing + ' tool(s) not found' : '✓ All tools installed'} -
-
- OS: ${data.os} | Package Manager: ${data.pkg_manager} -
-
- `; - - content.innerHTML = summaryHtml + html; - }) - .catch(err => { - content.innerHTML = '
Error loading dependencies: ' + err.message + '
'; - }); -} - -// Initialize settings on page load -document.addEventListener('DOMContentLoaded', () => { - Settings.init(); -}); - -// ============================================================================= -// Location Settings Functions -// ============================================================================= - -/** - * Load and display current observer location - */ -function loadObserverLocation() { - let lat = localStorage.getItem('observerLat'); - let lon = localStorage.getItem('observerLon'); - if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { - const shared = ObserverLocation.getShared(); - lat = shared.lat.toString(); - lon = shared.lon.toString(); - } - - const latInput = document.getElementById('observerLatInput'); - const lonInput = document.getElementById('observerLonInput'); - const currentLatDisplay = document.getElementById('currentLatDisplay'); - const currentLonDisplay = document.getElementById('currentLonDisplay'); - - if (latInput && lat) latInput.value = lat; - if (lonInput && lon) lonInput.value = lon; - - if (currentLatDisplay) { - currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set'; - } - if (currentLonDisplay) { - currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set'; - } - - // Sync dashboard-specific location keys for backward compatibility - if (lat && lon) { - const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) }); - if (!localStorage.getItem('observerLocation')) { - localStorage.setItem('observerLocation', locationObj); - } - if (!localStorage.getItem('ais_observerLocation')) { - localStorage.setItem('ais_observerLocation', locationObj); - } - } -} - -/** - * Detect location using gpsd (USB GPS) or browser geolocation as fallback - */ -function detectLocationGPS(btn) { - const latInput = document.getElementById('observerLatInput'); - const lonInput = document.getElementById('observerLonInput'); - - // Show loading state with visual feedback - const originalText = btn.innerHTML; - btn.innerHTML = ' Detecting...'; - btn.disabled = true; - btn.style.opacity = '0.7'; - - // Helper to restore button state - function restoreButton() { - btn.innerHTML = originalText; - btn.disabled = false; - btn.style.opacity = ''; - } - - // Helper to set location values - function setLocation(lat, lon, source) { - if (latInput) latInput.value = parseFloat(lat).toFixed(4); - if (lonInput) lonInput.value = parseFloat(lon).toFixed(4); - restoreButton(); - if (typeof showNotification === 'function') { - showNotification('Location', `Coordinates set from ${source}`); - } - } - - // First, try gpsd (USB GPS device) - fetch('/gps/position') - .then(response => response.json()) - .then(data => { - if (data.status === 'ok' && data.position && data.position.latitude != null) { - // Got valid position from gpsd - setLocation(data.position.latitude, data.position.longitude, 'GPS device'); - } else if (data.status === 'waiting') { - // gpsd connected but no fix yet - show message and try browser - if (typeof showNotification === 'function') { - showNotification('GPS', 'GPS device connected but no fix yet. Trying browser location...'); - } - useBrowserGeolocation(); - } else { - // gpsd not available, try browser geolocation - useBrowserGeolocation(); - } - }) - .catch(() => { - // gpsd request failed, try browser geolocation - useBrowserGeolocation(); - }); - - // Fallback to browser geolocation - function useBrowserGeolocation() { - if (!navigator.geolocation) { - restoreButton(); - if (typeof showNotification === 'function') { - showNotification('Location', 'No GPS available (gpsd not running, browser GPS unavailable)'); - } else { - alert('No GPS available'); - } - return; - } - - navigator.geolocation.getCurrentPosition( - (pos) => { - setLocation(pos.coords.latitude, pos.coords.longitude, 'browser'); - }, - (err) => { - restoreButton(); - let message = 'Failed to get location'; - if (err.code === 1) message = 'Location access denied'; - else if (err.code === 2) message = 'Location unavailable'; - else if (err.code === 3) message = 'Location request timed out'; - - if (typeof showNotification === 'function') { - showNotification('Location', message); - } else { - alert(message); - } - }, - { enableHighAccuracy: true, timeout: 10000 } - ); - } -} - -/** - * Save observer location to localStorage - */ -function saveObserverLocation() { - const latInput = document.getElementById('observerLatInput'); - const lonInput = document.getElementById('observerLonInput'); - - const lat = parseFloat(latInput?.value); - const lon = parseFloat(lonInput?.value); - - if (isNaN(lat) || lat < -90 || lat > 90) { - if (typeof showNotification === 'function') { - showNotification('Location', 'Invalid latitude (must be -90 to 90)'); - } else { - alert('Invalid latitude (must be -90 to 90)'); - } - return; - } - - if (isNaN(lon) || lon < -180 || lon > 180) { - if (typeof showNotification === 'function') { - showNotification('Location', 'Invalid longitude (must be -180 to 180)'); - } else { - alert('Invalid longitude (must be -180 to 180)'); - } - return; - } - - if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { - ObserverLocation.setShared({ lat, lon }); - } else { - localStorage.setItem('observerLat', lat.toString()); - localStorage.setItem('observerLon', lon.toString()); - } - - // Also update dashboard-specific location keys for ADS-B and AIS - const locationObj = JSON.stringify({ lat: lat, lon: lon }); - localStorage.setItem('observerLocation', locationObj); // ADS-B dashboard - localStorage.setItem('ais_observerLocation', locationObj); // AIS dashboard - - // Update display - const currentLatDisplay = document.getElementById('currentLatDisplay'); - const currentLonDisplay = document.getElementById('currentLonDisplay'); - if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°'; - if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°'; - - if (typeof showNotification === 'function') { - showNotification('Location', 'Observer location saved'); - } - - if (window.observerLocation) { - window.observerLocation.lat = lat; - window.observerLocation.lon = lon; - } - - // Refresh SSTV ISS schedule if available - if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') { - SSTV.loadIssSchedule(); - } -} - -// ============================================================================= -// Update Settings Functions -// ============================================================================= - -/** - * Check for updates manually from settings panel - */ -async function checkForUpdatesManual() { - const content = document.getElementById('updateStatusContent'); - if (!content) return; - - content.innerHTML = '
Checking for updates...
'; - - try { - const data = await Updater.checkNow(); - renderUpdateStatus(data); - } catch (error) { - content.innerHTML = `
Error checking for updates: ${error.message}
`; - } -} - -/** - * Load update status when tab is opened - */ -async function loadUpdateStatus() { - const content = document.getElementById('updateStatusContent'); - if (!content) return; - - try { - const data = await Updater.getStatus(); - renderUpdateStatus(data); - } catch (error) { - content.innerHTML = `
Error loading update status: ${error.message}
`; - } -} - -/** - * Render update status in settings panel - */ -function renderUpdateStatus(data) { - const content = document.getElementById('updateStatusContent'); - if (!content) return; - - if (!data.success) { - content.innerHTML = `
Error: ${data.error || 'Unknown error'}
`; - return; - } - - if (data.disabled) { - content.innerHTML = ` -
-
Update checking is disabled
-
- `; - return; - } - - if (!data.checked) { - content.innerHTML = ` -
-
No update check performed yet
-
Click "Check Now" to check for updates
-
- `; - return; - } - - const statusColor = data.update_available ? 'var(--accent-green)' : 'var(--text-dim)'; - const statusText = data.update_available ? 'Update Available' : 'Up to Date'; - const statusIcon = data.update_available - ? '' - : ''; - - let html = ` -
-
- ${statusIcon} - ${statusText} -
-
-
- Current Version - v${data.current_version} -
-
- Latest Version - v${data.latest_version} -
- ${data.last_check ? ` -
- Last Checked - ${formatLastCheck(data.last_check)} -
- ` : ''} -
- ${data.update_available ? ` - - ` : ''} -
- `; - - content.innerHTML = html; -} - -/** - * Format last check timestamp - */ -function formatLastCheck(isoString) { - try { - const date = new Date(isoString); - const now = new Date(); - const diffMs = now - date; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins} min ago`; - if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; - return date.toLocaleDateString(); - } catch (e) { - return isoString; - } -} - -/** - * Toggle update checking - */ -async function toggleUpdateCheck(enabled) { - // This would require adding a setting to disable update checks - // For now, just store in localStorage - localStorage.setItem('intercept_update_check_enabled', enabled ? 'true' : 'false'); - - if (!enabled && typeof Updater !== 'undefined') { - Updater.destroy(); - } else if (enabled && typeof Updater !== 'undefined') { - Updater.init(); - } -} - -// Extend switchSettingsTab to load update status -const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? switchSettingsTab : null; - -function switchSettingsTab(tabName) { - // Update tab buttons - document.querySelectorAll('.settings-tab').forEach(tab => { - tab.classList.toggle('active', tab.dataset.tab === tabName); - }); - - // Update sections - document.querySelectorAll('.settings-section').forEach(section => { - section.classList.toggle('active', section.id === `settings-${tabName}`); - }); - - // Load content based on tab - if (tabName === 'tools') { - loadSettingsTools(); - } else if (tabName === 'updates') { - loadUpdateStatus(); - } else if (tabName === 'location') { - loadObserverLocation(); - } -} +/** + * Settings Manager - Handles offline mode and application settings + */ + +const Settings = { + // Default settings + defaults: { + 'offline.enabled': false, + 'offline.assets_source': 'cdn', + 'offline.fonts_source': 'cdn', + 'offline.tile_provider': 'cartodb_dark', + 'offline.tile_server_url': '' + }, + + // Tile provider configurations + tileProviders: { + openstreetmap: { + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors', + subdomains: 'abc' + }, + cartodb_dark: { + url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', + attribution: '© OSM © CARTO', + subdomains: 'abcd' + }, + cartodb_light: { + url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', + attribution: '© OSM © CARTO', + subdomains: 'abcd' + }, + esri_world: { + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community', + subdomains: null + } + }, + + // Registry of maps that can be updated + _registeredMaps: [], + + // Current settings cache + _cache: {}, + + /** + * Initialize settings - load from server/localStorage + */ + async init() { + try { + const response = await fetch('/offline/settings'); + if (response.ok) { + const data = await response.json(); + this._cache = { ...this.defaults, ...data.settings }; + } else { + // Fall back to localStorage + this._loadFromLocalStorage(); + } + } catch (e) { + console.warn('Failed to load settings from server, using localStorage:', e); + this._loadFromLocalStorage(); + } + + this._updateUI(); + return this._cache; + }, + + /** + * Load settings from localStorage + */ + _loadFromLocalStorage() { + const stored = localStorage.getItem('intercept_settings'); + if (stored) { + try { + this._cache = { ...this.defaults, ...JSON.parse(stored) }; + } catch (e) { + this._cache = { ...this.defaults }; + } + } else { + this._cache = { ...this.defaults }; + } + }, + + /** + * Save a setting to server and localStorage + */ + async _save(key, value) { + this._cache[key] = value; + + // Save to localStorage as backup + localStorage.setItem('intercept_settings', JSON.stringify(this._cache)); + + // Save to server + try { + await fetch('/offline/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }) + }); + } catch (e) { + console.warn('Failed to save setting to server:', e); + } + }, + + /** + * Get a setting value + */ + get(key) { + return this._cache[key] ?? this.defaults[key]; + }, + + /** + * Toggle offline mode master switch + */ + async toggleOfflineMode(enabled) { + await this._save('offline.enabled', enabled); + + if (enabled) { + // When enabling offline mode, also switch assets and fonts to local + await this._save('offline.assets_source', 'local'); + await this._save('offline.fonts_source', 'local'); + } + + this._updateUI(); + this._showReloadPrompt(); + }, + + /** + * Set asset source (cdn or local) + */ + async setAssetSource(source) { + await this._save('offline.assets_source', source); + this._showReloadPrompt(); + }, + + /** + * Set fonts source (cdn or local) + */ + async setFontsSource(source) { + await this._save('offline.fonts_source', source); + this._showReloadPrompt(); + }, + + /** + * Set tile provider + */ + async setTileProvider(provider) { + await this._save('offline.tile_provider', provider); + + // Show/hide custom URL input + const customRow = document.getElementById('customTileUrlRow'); + if (customRow) { + customRow.style.display = provider === 'custom' ? 'block' : 'none'; + } + + // If not custom and we have a map, update tiles immediately + if (provider !== 'custom') { + this._updateMapTiles(); + } + }, + + /** + * Set custom tile server URL + */ + async setCustomTileUrl(url) { + await this._save('offline.tile_server_url', url); + this._updateMapTiles(); + }, + + /** + * Get current tile configuration + */ + getTileConfig() { + const provider = this.get('offline.tile_provider'); + + if (provider === 'custom') { + const customUrl = this.get('offline.tile_server_url'); + return { + url: customUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: 'Custom Tile Server', + subdomains: 'abc' + }; + } + + return this.tileProviders[provider] || this.tileProviders.cartodb_dark; + }, + + /** + * Register a map to receive tile updates when settings change + * @param {L.Map} map - Leaflet map instance + */ + registerMap(map) { + if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) { + this._registeredMaps.push(map); + } + }, + + /** + * Unregister a map + * @param {L.Map} map - Leaflet map instance + */ + unregisterMap(map) { + const idx = this._registeredMaps.indexOf(map); + if (idx > -1) { + this._registeredMaps.splice(idx, 1); + } + }, + + /** + * Create a tile layer using current settings + * @returns {L.TileLayer} Configured tile layer + */ + createTileLayer() { + const config = this.getTileConfig(); + const options = { + attribution: config.attribution, + maxZoom: 19 + }; + if (config.subdomains) { + options.subdomains = config.subdomains; + } + return L.tileLayer(config.url, options); + }, + + /** + * Check if local assets are available + */ + async checkAssets() { + const assets = { + leaflet: [ + '/static/vendor/leaflet/leaflet.js', + '/static/vendor/leaflet/leaflet.css' + ], + chartjs: [ + '/static/vendor/chartjs/chart.umd.min.js' + ], + inter: [ + '/static/vendor/fonts/Inter-Regular.woff2' + ], + jetbrains: [ + '/static/vendor/fonts/JetBrainsMono-Regular.woff2' + ] + }; + + const results = {}; + + for (const [name, urls] of Object.entries(assets)) { + const statusEl = document.getElementById(`status${name.charAt(0).toUpperCase() + name.slice(1)}`); + if (statusEl) { + statusEl.textContent = 'Checking...'; + statusEl.className = 'asset-badge checking'; + } + + let available = true; + for (const url of urls) { + try { + const response = await fetch(url, { method: 'HEAD' }); + if (!response.ok) { + available = false; + break; + } + } catch (e) { + available = false; + break; + } + } + + results[name] = available; + + if (statusEl) { + statusEl.textContent = available ? 'Available' : 'Missing'; + statusEl.className = `asset-badge ${available ? 'available' : 'missing'}`; + } + } + + return results; + }, + + /** + * Update UI elements to reflect current settings + */ + _updateUI() { + // Offline mode toggle + const offlineEnabled = document.getElementById('offlineEnabled'); + if (offlineEnabled) { + offlineEnabled.checked = this.get('offline.enabled'); + } + + // Assets source + const assetsSource = document.getElementById('assetsSource'); + if (assetsSource) { + assetsSource.value = this.get('offline.assets_source'); + } + + // Fonts source + const fontsSource = document.getElementById('fontsSource'); + if (fontsSource) { + fontsSource.value = this.get('offline.fonts_source'); + } + + // Tile provider + const tileProvider = document.getElementById('tileProvider'); + if (tileProvider) { + tileProvider.value = this.get('offline.tile_provider'); + } + + // Custom tile URL + const customTileUrl = document.getElementById('customTileUrl'); + if (customTileUrl) { + customTileUrl.value = this.get('offline.tile_server_url') || ''; + } + + // Show/hide custom URL row + const customRow = document.getElementById('customTileUrlRow'); + if (customRow) { + customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none'; + } + }, + + /** + * Update map tiles on all known maps + */ + _updateMapTiles() { + // Combine registered maps with common window map variables + const windowMaps = [ + window.map, + window.leafletMap, + window.aprsMap, + window.adsbMap, + window.radarMap, + window.vesselMap, + window.groundMap, + window.groundTrackMap, + window.meshMap + ].filter(m => m && typeof m.eachLayer === 'function'); + + // Combine with registered maps, removing duplicates + const allMaps = [...new Set([...this._registeredMaps, ...windowMaps])]; + + if (allMaps.length === 0) return; + + const config = this.getTileConfig(); + + allMaps.forEach(map => { + // Remove existing tile layers + map.eachLayer(layer => { + if (layer instanceof L.TileLayer) { + map.removeLayer(layer); + } + }); + + // Add new tile layer + const options = { + attribution: config.attribution, + maxZoom: 19 + }; + if (config.subdomains) { + options.subdomains = config.subdomains; + } + + L.tileLayer(config.url, options).addTo(map); + }); + }, + + /** + * Show reload prompt + */ + _showReloadPrompt() { + // Create or update reload prompt + let prompt = document.getElementById('settingsReloadPrompt'); + if (!prompt) { + prompt = document.createElement('div'); + prompt.id = 'settingsReloadPrompt'; + prompt.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: var(--bg-dark, #0a0a0f); + border: 1px solid var(--accent-cyan, #00d4ff); + border-radius: 8px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + z-index: 10001; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + `; + prompt.innerHTML = ` + + Reload to apply changes + + + + `; + document.body.appendChild(prompt); + } + } +}; + +// Settings modal functions +function showSettings() { + const modal = document.getElementById('settingsModal'); + if (modal) { + modal.classList.add('active'); + Settings.init().then(() => { + Settings.checkAssets(); + }); + } +} + +function hideSettings() { + const modal = document.getElementById('settingsModal'); + if (modal) { + modal.classList.remove('active'); + } +} + +function switchSettingsTab(tabName) { + // Update tab buttons + document.querySelectorAll('.settings-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.tab === tabName); + }); + + // Update sections + document.querySelectorAll('.settings-section').forEach(section => { + section.classList.toggle('active', section.id === `settings-${tabName}`); + }); + + // Load tools/dependencies when that tab is selected + if (tabName === 'tools') { + loadSettingsTools(); + } +} + +/** + * Load tool dependencies into settings modal + */ +function loadSettingsTools() { + const content = document.getElementById('settingsToolsContent'); + if (!content) return; + + content.innerHTML = '
Loading dependencies...
'; + + fetch('/dependencies') + .then(r => r.json()) + .then(data => { + if (data.status !== 'success') { + content.innerHTML = '
Error loading dependencies
'; + return; + } + + let html = ''; + let totalMissing = 0; + + for (const [modeKey, mode] of Object.entries(data.modes)) { + const statusColor = mode.ready ? 'var(--accent-green)' : 'var(--accent-red)'; + const statusIcon = mode.ready ? '✓' : '✗'; + + html += ` +
+
+ ${mode.name} + ${statusIcon} ${mode.ready ? 'Ready' : 'Missing'} +
+
+ `; + + for (const [toolName, tool] of Object.entries(mode.tools)) { + const installed = tool.installed; + const dotColor = installed ? 'var(--accent-green)' : 'var(--accent-red)'; + const requiredBadge = tool.required ? 'REQ' : ''; + + if (!installed) totalMissing++; + + let installCmd = ''; + if (tool.install) { + if (tool.install.pip) { + installCmd = tool.install.pip; + } else if (data.pkg_manager && tool.install[data.pkg_manager]) { + installCmd = tool.install[data.pkg_manager]; + } else if (tool.install.manual) { + installCmd = tool.install.manual; + } + } + + html += ` +
+ +
+ ${toolName}${requiredBadge} +
${tool.description}
+
+ ${!installed && installCmd ? ` + ${installCmd} + ` : ''} + ${installed ? 'OK' : 'MISSING'} +
+ `; + } + + html += '
'; + } + + // Summary at top + const summaryHtml = ` +
+
+ ${totalMissing > 0 ? '⚠️ ' + totalMissing + ' tool(s) not found' : '✓ All tools installed'} +
+
+ OS: ${data.os} | Package Manager: ${data.pkg_manager} +
+
+ `; + + content.innerHTML = summaryHtml + html; + }) + .catch(err => { + content.innerHTML = '
Error loading dependencies: ' + err.message + '
'; + }); +} + +// Initialize settings on page load +document.addEventListener('DOMContentLoaded', () => { + Settings.init(); +}); + +// ============================================================================= +// Location Settings Functions +// ============================================================================= + +/** + * Load and display current observer location + */ +function loadObserverLocation() { + let lat = localStorage.getItem('observerLat'); + let lon = localStorage.getItem('observerLon'); + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + const shared = ObserverLocation.getShared(); + lat = shared.lat.toString(); + lon = shared.lon.toString(); + } + + const latInput = document.getElementById('observerLatInput'); + const lonInput = document.getElementById('observerLonInput'); + const currentLatDisplay = document.getElementById('currentLatDisplay'); + const currentLonDisplay = document.getElementById('currentLonDisplay'); + + if (latInput && lat) latInput.value = lat; + if (lonInput && lon) lonInput.value = lon; + + if (currentLatDisplay) { + currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set'; + } + if (currentLonDisplay) { + currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set'; + } + + // Sync dashboard-specific location keys for backward compatibility + if (lat && lon) { + const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) }); + if (!localStorage.getItem('observerLocation')) { + localStorage.setItem('observerLocation', locationObj); + } + if (!localStorage.getItem('ais_observerLocation')) { + localStorage.setItem('ais_observerLocation', locationObj); + } + } +} + +/** + * Detect location using gpsd (USB GPS) or browser geolocation as fallback + */ +function detectLocationGPS(btn) { + const latInput = document.getElementById('observerLatInput'); + const lonInput = document.getElementById('observerLonInput'); + + // Show loading state with visual feedback + const originalText = btn.innerHTML; + btn.innerHTML = ' Detecting...'; + btn.disabled = true; + btn.style.opacity = '0.7'; + + // Helper to restore button state + function restoreButton() { + btn.innerHTML = originalText; + btn.disabled = false; + btn.style.opacity = ''; + } + + // Helper to set location values + function setLocation(lat, lon, source) { + if (latInput) latInput.value = parseFloat(lat).toFixed(4); + if (lonInput) lonInput.value = parseFloat(lon).toFixed(4); + restoreButton(); + if (typeof showNotification === 'function') { + showNotification('Location', `Coordinates set from ${source}`); + } + } + + // First, try gpsd (USB GPS device) + fetch('/gps/position') + .then(response => response.json()) + .then(data => { + if (data.status === 'ok' && data.position && data.position.latitude != null) { + // Got valid position from gpsd + setLocation(data.position.latitude, data.position.longitude, 'GPS device'); + } else if (data.status === 'waiting') { + // gpsd connected but no fix yet - show message and try browser + if (typeof showNotification === 'function') { + showNotification('GPS', 'GPS device connected but no fix yet. Trying browser location...'); + } + useBrowserGeolocation(); + } else { + // gpsd not available, try browser geolocation + useBrowserGeolocation(); + } + }) + .catch(() => { + // gpsd request failed, try browser geolocation + useBrowserGeolocation(); + }); + + // Fallback to browser geolocation + function useBrowserGeolocation() { + if (!navigator.geolocation) { + restoreButton(); + if (typeof showNotification === 'function') { + showNotification('Location', 'No GPS available (gpsd not running, browser GPS unavailable)'); + } else { + alert('No GPS available'); + } + return; + } + + navigator.geolocation.getCurrentPosition( + (pos) => { + setLocation(pos.coords.latitude, pos.coords.longitude, 'browser'); + }, + (err) => { + restoreButton(); + let message = 'Failed to get location'; + if (err.code === 1) message = 'Location access denied'; + else if (err.code === 2) message = 'Location unavailable'; + else if (err.code === 3) message = 'Location request timed out'; + + if (typeof showNotification === 'function') { + showNotification('Location', message); + } else { + alert(message); + } + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + } +} + +/** + * Save observer location to localStorage + */ +function saveObserverLocation() { + const latInput = document.getElementById('observerLatInput'); + const lonInput = document.getElementById('observerLonInput'); + + const lat = parseFloat(latInput?.value); + const lon = parseFloat(lonInput?.value); + + if (isNaN(lat) || lat < -90 || lat > 90) { + if (typeof showNotification === 'function') { + showNotification('Location', 'Invalid latitude (must be -90 to 90)'); + } else { + alert('Invalid latitude (must be -90 to 90)'); + } + return; + } + + if (isNaN(lon) || lon < -180 || lon > 180) { + if (typeof showNotification === 'function') { + showNotification('Location', 'Invalid longitude (must be -180 to 180)'); + } else { + alert('Invalid longitude (must be -180 to 180)'); + } + return; + } + + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + ObserverLocation.setShared({ lat, lon }); + } else { + localStorage.setItem('observerLat', lat.toString()); + localStorage.setItem('observerLon', lon.toString()); + } + + // Also update dashboard-specific location keys for ADS-B and AIS + const locationObj = JSON.stringify({ lat: lat, lon: lon }); + localStorage.setItem('observerLocation', locationObj); // ADS-B dashboard + localStorage.setItem('ais_observerLocation', locationObj); // AIS dashboard + + // Update display + const currentLatDisplay = document.getElementById('currentLatDisplay'); + const currentLonDisplay = document.getElementById('currentLonDisplay'); + if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°'; + if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°'; + + if (typeof showNotification === 'function') { + showNotification('Location', 'Observer location saved'); + } + + if (window.observerLocation) { + window.observerLocation.lat = lat; + window.observerLocation.lon = lon; + } + + // Refresh SSTV ISS schedule if available + if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') { + SSTV.loadIssSchedule(); + } +} + +// ============================================================================= +// Update Settings Functions +// ============================================================================= + +/** + * Check for updates manually from settings panel + */ +async function checkForUpdatesManual() { + const content = document.getElementById('updateStatusContent'); + if (!content) return; + + content.innerHTML = '
Checking for updates...
'; + + try { + const data = await Updater.checkNow(); + renderUpdateStatus(data); + } catch (error) { + content.innerHTML = `
Error checking for updates: ${error.message}
`; + } +} + +/** + * Load update status when tab is opened + */ +async function loadUpdateStatus() { + const content = document.getElementById('updateStatusContent'); + if (!content) return; + + try { + const data = await Updater.getStatus(); + renderUpdateStatus(data); + } catch (error) { + content.innerHTML = `
Error loading update status: ${error.message}
`; + } +} + +/** + * Render update status in settings panel + */ +function renderUpdateStatus(data) { + const content = document.getElementById('updateStatusContent'); + if (!content) return; + + if (!data.success) { + content.innerHTML = `
Error: ${data.error || 'Unknown error'}
`; + return; + } + + if (data.disabled) { + content.innerHTML = ` +
+
Update checking is disabled
+
+ `; + return; + } + + if (!data.checked) { + content.innerHTML = ` +
+
No update check performed yet
+
Click "Check Now" to check for updates
+
+ `; + return; + } + + const statusColor = data.update_available ? 'var(--accent-green)' : 'var(--text-dim)'; + const statusText = data.update_available ? 'Update Available' : 'Up to Date'; + const statusIcon = data.update_available + ? '' + : ''; + + let html = ` +
+
+ ${statusIcon} + ${statusText} +
+
+
+ Current Version + v${data.current_version} +
+
+ Latest Version + v${data.latest_version} +
+ ${data.last_check ? ` +
+ Last Checked + ${formatLastCheck(data.last_check)} +
+ ` : ''} +
+ ${data.update_available ? ` + + ` : ''} +
+ `; + + content.innerHTML = html; +} + +/** + * Format last check timestamp + */ +function formatLastCheck(isoString) { + try { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins} min ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + return date.toLocaleDateString(); + } catch (e) { + return isoString; + } +} + +/** + * Toggle update checking + */ +async function toggleUpdateCheck(enabled) { + // This would require adding a setting to disable update checks + // For now, just store in localStorage + localStorage.setItem('intercept_update_check_enabled', enabled ? 'true' : 'false'); + + if (!enabled && typeof Updater !== 'undefined') { + Updater.destroy(); + } else if (enabled && typeof Updater !== 'undefined') { + Updater.init(); + } +} + +// Extend switchSettingsTab to load update status +const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? switchSettingsTab : null; + +function switchSettingsTab(tabName) { + // Update tab buttons + document.querySelectorAll('.settings-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.tab === tabName); + }); + + // Update sections + document.querySelectorAll('.settings-section').forEach(section => { + section.classList.toggle('active', section.id === `settings-${tabName}`); + }); + + // Load content based on tab + if (tabName === 'tools') { + loadSettingsTools(); + } else if (tabName === 'updates') { + loadUpdateStatus(); + } else if (tabName === 'location') { + loadObserverLocation(); + } +} diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 1f991f4..c3dc4c2 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -1,2599 +1,2599 @@ -/** - * Intercept - Listening Post Mode - * Frequency scanner and manual audio receiver - */ - -// ============== STATE ============== - -let isScannerRunning = false; -let isScannerPaused = false; -let scannerEventSource = null; -let scannerSignalCount = 0; -let scannerLogEntries = []; -let scannerFreqsScanned = 0; -let scannerCycles = 0; -let scannerStartFreq = 118; -let scannerEndFreq = 137; -let scannerSignalActive = false; - -// Audio state -let isAudioPlaying = false; -let audioToolsAvailable = { rtl_fm: false, ffmpeg: false }; -let audioReconnectAttempts = 0; -const MAX_AUDIO_RECONNECT = 3; - -// WebSocket audio state -let audioWebSocket = null; -let audioQueue = []; -let isWebSocketAudio = false; - -// Visualizer state -let visualizerContext = null; -let visualizerAnalyser = null; -let visualizerSource = null; -let visualizerAnimationId = null; -let peakLevel = 0; -let peakDecay = 0.95; - -// Signal level for synthesizer visualization -let currentSignalLevel = 0; -let signalLevelThreshold = 1000; - -// Track recent signal hits to prevent duplicates -let recentSignalHits = new Map(); - -// Direct listen state -let isDirectListening = false; -let currentModulation = 'am'; - -// Agent mode state -let listeningPostCurrentAgent = null; -let listeningPostPollTimer = null; - -// ============== PRESETS ============== - -const scannerPresets = { - fm: { start: 88, end: 108, step: 200, mod: 'wfm' }, - air: { start: 118, end: 137, step: 25, mod: 'am' }, - marine: { start: 156, end: 163, step: 25, mod: 'fm' }, - amateur2m: { start: 144, end: 148, step: 12.5, mod: 'fm' }, - pager: { start: 152, end: 160, step: 25, mod: 'fm' }, - amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' } -}; - -const audioPresets = { - fm: { freq: 98.1, mod: 'wfm' }, - airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency - marine: { freq: 156.8, mod: 'fm' }, // Channel 16 - distress - amateur2m: { freq: 146.52, mod: 'fm' }, // 2m calling frequency - amateur70cm: { freq: 446.0, mod: 'fm' } -}; - -// ============== SCANNER TOOLS CHECK ============== - -function checkScannerTools() { - fetch('/listening/tools') - .then(r => r.json()) - .then(data => { - const warnings = []; - if (!data.rtl_fm) { - warnings.push('rtl_fm not found - install rtl-sdr tools'); - } - if (!data.ffmpeg) { - warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)'); - } - - const warningDiv = document.getElementById('scannerToolsWarning'); - const warningText = document.getElementById('scannerToolsWarningText'); - if (warningDiv && warnings.length > 0) { - warningText.innerHTML = warnings.join('
'); - warningDiv.style.display = 'block'; - document.getElementById('scannerStartBtn').disabled = true; - document.getElementById('scannerStartBtn').style.opacity = '0.5'; - } else if (warningDiv) { - warningDiv.style.display = 'none'; - document.getElementById('scannerStartBtn').disabled = false; - document.getElementById('scannerStartBtn').style.opacity = '1'; - } - }) - .catch(() => {}); -} - -// ============== SCANNER HELPERS ============== - -/** - * Get the currently selected device from the global SDR selector - */ -function getSelectedDevice() { - const select = document.getElementById('deviceSelect'); - return parseInt(select?.value || '0'); -} - -/** - * Get the currently selected SDR type from the global selector - */ -function getSelectedSDRTypeForScanner() { - const select = document.getElementById('sdrTypeSelect'); - return select?.value || 'rtlsdr'; -} - -// ============== SCANNER PRESETS ============== - -function applyScannerPreset() { - const preset = document.getElementById('scannerPreset').value; - if (preset !== 'custom' && scannerPresets[preset]) { - const p = scannerPresets[preset]; - document.getElementById('scannerStartFreq').value = p.start; - document.getElementById('scannerEndFreq').value = p.end; - document.getElementById('scannerStep').value = p.step; - document.getElementById('scannerModulation').value = p.mod; - } -} - -// ============== SCANNER CONTROLS ============== - -function toggleScanner() { - if (isScannerRunning) { - stopScanner(); - } else { - startScanner(); - } -} - -function startScanner() { - // Use unified radio controls - read all current UI values - const startFreq = parseFloat(document.getElementById('radioScanStart')?.value || 118); - const endFreq = parseFloat(document.getElementById('radioScanEnd')?.value || 137); - const stepSelect = document.getElementById('radioScanStep'); - const step = stepSelect ? parseFloat(stepSelect.value) : 25; - const modulation = currentModulation || 'am'; - const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; - const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; - const dwellSelect = document.getElementById('radioScanDwell'); - const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10; - const device = getSelectedDevice(); - - // Check if using agent mode - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - listeningPostCurrentAgent = isAgentMode ? currentAgent : null; - - // Disable listen button for agent mode (audio can't stream over HTTP) - updateListenButtonState(isAgentMode); - - if (startFreq >= endFreq) { - if (typeof showNotification === 'function') { - showNotification('Scanner Error', 'End frequency must be greater than start'); - } - return; - } - - // Check if device is available (only for local mode) - if (!isAgentMode && typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) { - return; - } - - // Store scanner range for progress calculation - scannerStartFreq = startFreq; - scannerEndFreq = endFreq; - scannerFreqsScanned = 0; - scannerCycles = 0; - - // Update sidebar display - updateScannerDisplay('STARTING...', 'var(--accent-orange)'); - - // Show progress bars - const progressEl = document.getElementById('scannerProgress'); - if (progressEl) { - progressEl.style.display = 'block'; - document.getElementById('scannerRangeStart').textContent = startFreq.toFixed(1); - document.getElementById('scannerRangeEnd').textContent = endFreq.toFixed(1); - } - - const mainProgress = document.getElementById('mainScannerProgress'); - if (mainProgress) { - mainProgress.style.display = 'block'; - document.getElementById('mainRangeStart').textContent = startFreq.toFixed(1) + ' MHz'; - document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz'; - } - - // Determine endpoint based on agent mode - const endpoint = isAgentMode - ? `/controller/agents/${currentAgent}/listening_post/start` - : '/listening/scanner/start'; - - fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - start_freq: startFreq, - end_freq: endFreq, - step: step, - modulation: modulation, - squelch: squelch, - gain: gain, - dwell_time: dwell, - device: device, - bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false - }) - }) - .then(r => r.json()) - .then(data => { - // Handle controller proxy response format - const scanResult = isAgentMode && data.result ? data.result : data; - - if (scanResult.status === 'started' || scanResult.status === 'success') { - if (!isAgentMode && typeof reserveDevice === 'function') reserveDevice(device, 'scanner'); - isScannerRunning = true; - isScannerPaused = false; - scannerSignalActive = false; - - // Update controls (with null checks) - const startBtn = document.getElementById('scannerStartBtn'); - if (startBtn) { - startBtn.textContent = 'Stop Scanner'; - startBtn.classList.add('active'); - } - const pauseBtn = document.getElementById('scannerPauseBtn'); - if (pauseBtn) pauseBtn.disabled = false; - - // Update radio scan button to show STOP - const radioScanBtn = document.getElementById('radioScanBtn'); - if (radioScanBtn) { - radioScanBtn.innerHTML = Icons.stop('icon--sm') + ' STOP'; - radioScanBtn.style.background = 'var(--accent-red)'; - radioScanBtn.style.borderColor = 'var(--accent-red)'; - } - - updateScannerDisplay('SCANNING', 'var(--accent-cyan)'); - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'Scanning...'; - - // Show level meter - const levelMeter = document.getElementById('scannerLevelMeter'); - if (levelMeter) levelMeter.style.display = 'block'; - - connectScannerStream(isAgentMode); - addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`); - if (typeof showNotification === 'function') { - showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`); - } - } else { - updateScannerDisplay('ERROR', 'var(--accent-red)'); - if (typeof showNotification === 'function') { - showNotification('Scanner Error', scanResult.message || scanResult.error || 'Failed to start'); - } - } - }) - .catch(err => { - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'ERROR'; - updateScannerDisplay('ERROR', 'var(--accent-red)'); - if (typeof showNotification === 'function') { - showNotification('Scanner Error', err.message); - } - }); -} - -function stopScanner() { - const isAgentMode = listeningPostCurrentAgent !== null; - const endpoint = isAgentMode - ? `/controller/agents/${listeningPostCurrentAgent}/listening_post/stop` - : '/listening/scanner/stop'; - - fetch(endpoint, { method: 'POST' }) - .then(() => { - if (!isAgentMode && typeof releaseDevice === 'function') releaseDevice('scanner'); - listeningPostCurrentAgent = null; - isScannerRunning = false; - isScannerPaused = false; - scannerSignalActive = false; - currentSignalLevel = 0; - - // Re-enable listen button (will be in local mode after stop) - updateListenButtonState(false); - - // Clear polling timer - if (listeningPostPollTimer) { - clearInterval(listeningPostPollTimer); - listeningPostPollTimer = null; - } - - // Update sidebar (with null checks) - const startBtn = document.getElementById('scannerStartBtn'); - if (startBtn) { - startBtn.textContent = 'Start Scanner'; - startBtn.classList.remove('active'); - } - const pauseBtn = document.getElementById('scannerPauseBtn'); - if (pauseBtn) { - pauseBtn.disabled = true; - pauseBtn.innerHTML = Icons.pause('icon--sm') + ' Pause'; - } - - // Update radio scan button - const radioScanBtn = document.getElementById('radioScanBtn'); - if (radioScanBtn) { - radioScanBtn.innerHTML = '📡 SCAN'; - radioScanBtn.style.background = ''; - radioScanBtn.style.borderColor = ''; - } - - updateScannerDisplay('STOPPED', 'var(--text-muted)'); - const currentFreq = document.getElementById('scannerCurrentFreq'); - if (currentFreq) currentFreq.textContent = '---.--- MHz'; - const modLabel = document.getElementById('scannerModLabel'); - if (modLabel) modLabel.textContent = '--'; - - const progressEl = document.getElementById('scannerProgress'); - if (progressEl) progressEl.style.display = 'none'; - - const signalPanel = document.getElementById('scannerSignalPanel'); - if (signalPanel) signalPanel.style.display = 'none'; - - const levelMeter = document.getElementById('scannerLevelMeter'); - if (levelMeter) levelMeter.style.display = 'none'; - - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'Ready'; - - // Update main display - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) { - mainModeLabel.textContent = 'SCANNER STOPPED'; - document.getElementById('mainScannerFreq').textContent = '---.---'; - document.getElementById('mainScannerFreq').style.color = 'var(--text-muted)'; - document.getElementById('mainScannerMod').textContent = '--'; - } - - const mainAnim = document.getElementById('mainScannerAnimation'); - if (mainAnim) mainAnim.style.display = 'none'; - - const mainProgress = document.getElementById('mainScannerProgress'); - if (mainProgress) mainProgress.style.display = 'none'; - - const mainSignalAlert = document.getElementById('mainSignalAlert'); - if (mainSignalAlert) mainSignalAlert.style.display = 'none'; - - // Stop scanner audio - const scannerAudio = document.getElementById('scannerAudioPlayer'); - if (scannerAudio) { - scannerAudio.pause(); - scannerAudio.src = ''; - } - - if (scannerEventSource) { - scannerEventSource.close(); - scannerEventSource = null; - } - addScannerLogEntry('Scanner stopped', ''); - }) - .catch(() => {}); -} - -function pauseScanner() { - const endpoint = isScannerPaused ? '/listening/scanner/resume' : '/listening/scanner/pause'; - fetch(endpoint, { method: 'POST' }) - .then(r => r.json()) - .then(data => { - isScannerPaused = !isScannerPaused; - const pauseBtn = document.getElementById('scannerPauseBtn'); - if (pauseBtn) pauseBtn.innerHTML = isScannerPaused ? Icons.play('icon--sm') + ' Resume' : Icons.pause('icon--sm') + ' Pause'; - const statusText = document.getElementById('scannerStatusText'); - if (statusText) { - statusText.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; - statusText.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; - } - - const activityStatus = document.getElementById('scannerActivityStatus'); - if (activityStatus) { - activityStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; - activityStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; - } - - // Update main display - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) { - mainModeLabel.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; - } - - addScannerLogEntry(isScannerPaused ? 'Scanner paused' : 'Scanner resumed', ''); - }) - .catch(() => {}); -} - -function skipSignal() { - if (!isScannerRunning) { - if (typeof showNotification === 'function') { - showNotification('Scanner', 'Scanner is not running'); - } - return; - } - - fetch('/listening/scanner/skip', { method: 'POST' }) - .then(r => r.json()) - .then(data => { - if (data.status === 'skipped' && typeof showNotification === 'function') { - showNotification('Signal Skipped', `Continuing scan from ${data.frequency.toFixed(3)} MHz`); - } - }) - .catch(err => { - if (typeof showNotification === 'function') { - showNotification('Skip Error', err.message); - } - }); -} - -// ============== SCANNER STREAM ============== - -function connectScannerStream(isAgentMode = false) { - if (scannerEventSource) { - scannerEventSource.close(); - } - - // Use different stream endpoint for agent mode - const streamUrl = isAgentMode ? '/controller/stream/all' : '/listening/scanner/stream'; - scannerEventSource = new EventSource(streamUrl); - - scannerEventSource.onmessage = function(e) { - try { - const data = JSON.parse(e.data); - - if (isAgentMode) { - // Handle multi-agent stream format - if (data.scan_type === 'listening_post' && data.payload) { - const payload = data.payload; - payload.agent_name = data.agent_name; - handleScannerEvent(payload); - } - } else { - handleScannerEvent(data); - } - } catch (err) { - console.warn('Scanner parse error:', err); - } - }; - - scannerEventSource.onerror = function() { - if (isScannerRunning) { - setTimeout(() => connectScannerStream(isAgentMode), 2000); - } - }; - - // Start polling fallback for agent mode - if (isAgentMode) { - startListeningPostPolling(); - } -} - -// Track last activity count for polling -let lastListeningPostActivityCount = 0; - -function startListeningPostPolling() { - if (listeningPostPollTimer) return; - lastListeningPostActivityCount = 0; - - // Disable listen button for agent mode (audio can't stream over HTTP) - updateListenButtonState(true); - - const pollInterval = 2000; - listeningPostPollTimer = setInterval(async () => { - if (!isScannerRunning || !listeningPostCurrentAgent) { - clearInterval(listeningPostPollTimer); - listeningPostPollTimer = null; - return; - } - - try { - const response = await fetch(`/controller/agents/${listeningPostCurrentAgent}/listening_post/data`); - if (!response.ok) return; - - const data = await response.json(); - const result = data.result || data; - // Controller returns nested structure: data.data.data for agent mode data - const outerData = result.data || {}; - const modeData = outerData.data || outerData; - - // Process activity from polling response - const activity = modeData.activity || []; - if (activity.length > lastListeningPostActivityCount) { - const newActivity = activity.slice(lastListeningPostActivityCount); - newActivity.forEach(item => { - // Convert to scanner event format - const event = { - type: 'signal_found', - frequency: item.frequency, - level: item.level || item.signal_level, - modulation: item.modulation, - agent_name: result.agent_name || 'Remote Agent' - }; - handleScannerEvent(event); - }); - lastListeningPostActivityCount = activity.length; - } - - // Update current frequency if available - if (modeData.current_freq) { - handleScannerEvent({ - type: 'freq_change', - frequency: modeData.current_freq - }); - } - - // Update freqs scanned counter from agent data - if (modeData.freqs_scanned !== undefined) { - const freqsEl = document.getElementById('mainFreqsScanned'); - if (freqsEl) freqsEl.textContent = modeData.freqs_scanned; - scannerFreqsScanned = modeData.freqs_scanned; - } - - // Update signal count from agent data - if (modeData.signal_count !== undefined) { - const signalEl = document.getElementById('mainSignalCount'); - if (signalEl) signalEl.textContent = modeData.signal_count; - } - } catch (err) { - console.error('Listening Post polling error:', err); - } - }, pollInterval); -} - -function handleScannerEvent(data) { - switch (data.type) { - case 'freq_change': - case 'scan_update': - handleFrequencyUpdate(data); - break; - case 'signal_found': - handleSignalFound(data); - break; - case 'signal_lost': - case 'signal_skipped': - handleSignalLost(data); - break; - case 'log': - if (data.entry && data.entry.type === 'scan_cycle') { - scannerCycles++; - const cyclesEl = document.getElementById('mainScanCycles'); - if (cyclesEl) cyclesEl.textContent = scannerCycles; - } - break; - case 'stopped': - stopScanner(); - break; - } -} - -function handleFrequencyUpdate(data) { - const freqStr = data.frequency.toFixed(3); - - const currentFreq = document.getElementById('scannerCurrentFreq'); - if (currentFreq) currentFreq.textContent = freqStr + ' MHz'; - - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.textContent = freqStr; - - // Update progress bar - const progress = ((data.frequency - scannerStartFreq) / (scannerEndFreq - scannerStartFreq)) * 100; - const progressBar = document.getElementById('scannerProgressBar'); - if (progressBar) progressBar.style.width = Math.max(0, Math.min(100, progress)) + '%'; - - const mainProgressBar = document.getElementById('mainProgressBar'); - if (mainProgressBar) mainProgressBar.style.width = Math.max(0, Math.min(100, progress)) + '%'; - - scannerFreqsScanned++; - const freqsEl = document.getElementById('mainFreqsScanned'); - if (freqsEl) freqsEl.textContent = scannerFreqsScanned; - - // Update level meter if present - if (data.level !== undefined) { - // Store for synthesizer visualization - currentSignalLevel = data.level; - if (data.threshold !== undefined) { - signalLevelThreshold = data.threshold; - } - - const levelPercent = Math.min(100, (data.level / 5000) * 100); - const levelBar = document.getElementById('scannerLevelBar'); - if (levelBar) { - levelBar.style.width = levelPercent + '%'; - if (data.detected) { - levelBar.style.background = 'var(--accent-green)'; - } else if (data.level > (data.threshold || 0) * 0.7) { - levelBar.style.background = 'var(--accent-orange)'; - } else { - levelBar.style.background = 'var(--accent-cyan)'; - } - } - const levelValue = document.getElementById('scannerLevelValue'); - if (levelValue) levelValue.textContent = data.level; - } - - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = `${freqStr} MHz${data.level !== undefined ? ` (level: ${data.level})` : ''}`; -} - -function handleSignalFound(data) { - scannerSignalCount++; - scannerSignalActive = true; - const freqStr = data.frequency.toFixed(3); - - const signalCount = document.getElementById('scannerSignalCount'); - if (signalCount) signalCount.textContent = scannerSignalCount; - const mainSignalCount = document.getElementById('mainSignalCount'); - if (mainSignalCount) mainSignalCount.textContent = scannerSignalCount; - - // Update sidebar - updateScannerDisplay('SIGNAL FOUND', 'var(--accent-green)'); - const signalPanel = document.getElementById('scannerSignalPanel'); - if (signalPanel) signalPanel.style.display = 'block'; - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'Listening to signal...'; - - // Update main display - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) mainModeLabel.textContent = 'SIGNAL DETECTED'; - - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.style.color = 'var(--accent-green)'; - - const mainAnim = document.getElementById('mainScannerAnimation'); - if (mainAnim) mainAnim.style.display = 'none'; - - const mainSignalAlert = document.getElementById('mainSignalAlert'); - if (mainSignalAlert) mainSignalAlert.style.display = 'block'; - - // Start audio playback for the detected signal - if (data.audio_streaming) { - const scannerAudio = document.getElementById('scannerAudioPlayer'); - if (scannerAudio) { - // Pass the signal frequency and modulation to getStreamUrl - const streamUrl = getStreamUrl(data.frequency, data.modulation); - console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz'); - scannerAudio.src = streamUrl; - // Apply current volume from knob - const volumeKnob = document.getElementById('radioVolumeKnob'); - if (volumeKnob && volumeKnob._knob) { - scannerAudio.volume = volumeKnob._knob.getValue() / 100; - } else if (volumeKnob) { - const knobValue = parseFloat(volumeKnob.dataset.value) || 80; - scannerAudio.volume = knobValue / 100; - } - scannerAudio.play().catch(e => console.warn('[SCANNER] Audio autoplay blocked:', e)); - // Initialize audio visualizer to feed signal levels to synthesizer - initAudioVisualizer(); - } - } - - // Add to sidebar recent signals - if (typeof addSidebarRecentSignal === 'function') { - addSidebarRecentSignal(data.frequency, data.modulation); - } - - addScannerLogEntry('SIGNAL FOUND', `${freqStr} MHz (${data.modulation.toUpperCase()})`, 'signal'); - addSignalHit(data); - - if (typeof showNotification === 'function') { - showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`); - } -} - -function handleSignalLost(data) { - scannerSignalActive = false; - - // Update sidebar - updateScannerDisplay('SCANNING', 'var(--accent-cyan)'); - const signalPanel = document.getElementById('scannerSignalPanel'); - if (signalPanel) signalPanel.style.display = 'none'; - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'Scanning...'; - - // Update main display - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) mainModeLabel.textContent = 'SCANNING'; - - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.style.color = 'var(--accent-cyan)'; - - const mainAnim = document.getElementById('mainScannerAnimation'); - if (mainAnim) mainAnim.style.display = 'block'; - - const mainSignalAlert = document.getElementById('mainSignalAlert'); - if (mainSignalAlert) mainSignalAlert.style.display = 'none'; - - // Stop audio - const scannerAudio = document.getElementById('scannerAudioPlayer'); - if (scannerAudio) { - scannerAudio.pause(); - scannerAudio.src = ''; - } - - const logType = data.type === 'signal_skipped' ? 'info' : 'info'; - const logTitle = data.type === 'signal_skipped' ? 'Signal skipped' : 'Signal lost'; - addScannerLogEntry(logTitle, `${data.frequency.toFixed(3)} MHz`, logType); -} - -/** - * Update listen button state based on agent mode - * Audio streaming isn't practical over HTTP so disable for remote agents - */ -function updateListenButtonState(isAgentMode) { - const listenBtn = document.getElementById('radioListenBtn'); - if (!listenBtn) return; - - if (isAgentMode) { - listenBtn.disabled = true; - listenBtn.style.opacity = '0.5'; - listenBtn.style.cursor = 'not-allowed'; - listenBtn.title = 'Audio listening not available for remote agents'; - } else { - listenBtn.disabled = false; - listenBtn.style.opacity = '1'; - listenBtn.style.cursor = 'pointer'; - listenBtn.title = 'Listen to current frequency'; - } -} - -function updateScannerDisplay(mode, color) { - const modeLabel = document.getElementById('scannerModeLabel'); - if (modeLabel) { - modeLabel.textContent = mode; - modeLabel.style.color = color; - } - - const currentFreq = document.getElementById('scannerCurrentFreq'); - if (currentFreq) currentFreq.style.color = color; - - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) mainModeLabel.textContent = mode; - - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.style.color = color; -} - -// ============== SCANNER LOG ============== - -function addScannerLogEntry(title, detail, type = 'info') { - const now = new Date(); - const timestamp = now.toLocaleTimeString(); - const entry = { timestamp, title, detail, type }; - scannerLogEntries.unshift(entry); - - if (scannerLogEntries.length > 100) { - scannerLogEntries.pop(); - } - - // Color based on type - const getTypeColor = (t) => { - switch(t) { - case 'signal': return 'var(--accent-green)'; - case 'error': return 'var(--accent-red)'; - default: return 'var(--text-secondary)'; - } - }; - - // Update sidebar log - const sidebarLog = document.getElementById('scannerLog'); - if (sidebarLog) { - sidebarLog.innerHTML = scannerLogEntries.slice(0, 20).map(e => - `
- [${e.timestamp}] - ${e.title} ${e.detail} -
` - ).join(''); - } - - // Update main activity log - const activityLog = document.getElementById('scannerActivityLog'); - if (activityLog) { - const getBorderColor = (t) => { - switch(t) { - case 'signal': return 'var(--accent-green)'; - case 'error': return 'var(--accent-red)'; - default: return 'var(--border-color)'; - } - }; - activityLog.innerHTML = scannerLogEntries.slice(0, 50).map(e => - `
- [${e.timestamp}] - ${e.title} - ${e.detail} -
` - ).join(''); - } -} - -function addSignalHit(data) { - const tbody = document.getElementById('scannerHitsBody'); - if (!tbody) return; - - const now = Date.now(); - const freqKey = data.frequency.toFixed(3); - - // Check for duplicate - if (recentSignalHits.has(freqKey)) { - const lastHit = recentSignalHits.get(freqKey); - if (now - lastHit < 5000) return; - } - recentSignalHits.set(freqKey, now); - - // Clean up old entries - for (const [freq, time] of recentSignalHits) { - if (now - time > 30000) { - recentSignalHits.delete(freq); - } - } - - const timestamp = new Date().toLocaleTimeString(); - - if (tbody.innerHTML.includes('No signals detected')) { - tbody.innerHTML = ''; - } - - const mod = data.modulation || 'fm'; - const row = document.createElement('tr'); - row.style.borderBottom = '1px solid var(--border-color)'; - row.innerHTML = ` - ${timestamp} - ${data.frequency.toFixed(3)} - ${mod.toUpperCase()} - - - - `; - tbody.insertBefore(row, tbody.firstChild); - - while (tbody.children.length > 50) { - tbody.removeChild(tbody.lastChild); - } - - const hitCount = document.getElementById('scannerHitCount'); - if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`; - - // Feed to activity timeline if available - if (typeof addTimelineEvent === 'function') { - const normalized = typeof RFTimelineAdapter !== 'undefined' - ? RFTimelineAdapter.normalizeSignal({ - frequency: data.frequency, - rssi: data.rssi || data.signal_strength, - duration: data.duration || 2000, - modulation: data.modulation - }) - : { - id: String(data.frequency), - label: `${data.frequency.toFixed(3)} MHz`, - strength: 3, - duration: 2000, - type: 'rf' - }; - addTimelineEvent('listening', normalized); - } -} - -function clearScannerLog() { - scannerLogEntries = []; - scannerSignalCount = 0; - scannerFreqsScanned = 0; - scannerCycles = 0; - recentSignalHits.clear(); - - // Clear the timeline if available - const timeline = typeof getTimeline === 'function' ? getTimeline('listening') : null; - if (timeline) { - timeline.clear(); - } - - const signalCount = document.getElementById('scannerSignalCount'); - if (signalCount) signalCount.textContent = '0'; - - const mainSignalCount = document.getElementById('mainSignalCount'); - if (mainSignalCount) mainSignalCount.textContent = '0'; - - const mainFreqsScanned = document.getElementById('mainFreqsScanned'); - if (mainFreqsScanned) mainFreqsScanned.textContent = '0'; - - const mainScanCycles = document.getElementById('mainScanCycles'); - if (mainScanCycles) mainScanCycles.textContent = '0'; - - const sidebarLog = document.getElementById('scannerLog'); - if (sidebarLog) sidebarLog.innerHTML = '
Scanner activity will appear here...
'; - - const activityLog = document.getElementById('scannerActivityLog'); - if (activityLog) activityLog.innerHTML = '
Waiting for scanner to start...
'; - - const hitsBody = document.getElementById('scannerHitsBody'); - if (hitsBody) hitsBody.innerHTML = 'No signals detected'; - - const hitCount = document.getElementById('scannerHitCount'); - if (hitCount) hitCount.textContent = '0 signals found'; -} - -function exportScannerLog() { - if (scannerLogEntries.length === 0) { - if (typeof showNotification === 'function') { - showNotification('Export', 'No log entries to export'); - } - return; - } - - const csv = 'Timestamp,Event,Details\n' + scannerLogEntries.map(e => - `"${e.timestamp}","${e.title}","${e.detail}"` - ).join('\n'); - - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `scanner_log_${new Date().toISOString().slice(0, 10)}.csv`; - a.click(); - URL.revokeObjectURL(url); - - if (typeof showNotification === 'function') { - showNotification('Export', 'Log exported to CSV'); - } -} - -// ============== AUDIO TOOLS CHECK ============== - -function checkAudioTools() { - fetch('/listening/tools') - .then(r => r.json()) - .then(data => { - audioToolsAvailable.rtl_fm = data.rtl_fm; - audioToolsAvailable.ffmpeg = data.ffmpeg; - - // Only rtl_fm/rx_fm + ffmpeg are required for direct streaming - const warnings = []; - if (!data.rtl_fm && !data.rx_fm) { - warnings.push('rtl_fm/rx_fm not found - install rtl-sdr or soapysdr-tools'); - } - if (!data.ffmpeg) { - warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)'); - } - - const warningDiv = document.getElementById('audioToolsWarning'); - const warningText = document.getElementById('audioToolsWarningText'); - if (warningDiv) { - if (warnings.length > 0) { - warningText.innerHTML = warnings.join('
'); - warningDiv.style.display = 'block'; - document.getElementById('audioStartBtn').disabled = true; - document.getElementById('audioStartBtn').style.opacity = '0.5'; - } else { - warningDiv.style.display = 'none'; - document.getElementById('audioStartBtn').disabled = false; - document.getElementById('audioStartBtn').style.opacity = '1'; - } - } - }) - .catch(() => {}); -} - -// ============== AUDIO PRESETS ============== - -function applyAudioPreset() { - const preset = document.getElementById('audioPreset').value; - const freqInput = document.getElementById('audioFrequency'); - const modSelect = document.getElementById('audioModulation'); - - if (audioPresets[preset]) { - freqInput.value = audioPresets[preset].freq; - modSelect.value = audioPresets[preset].mod; - } -} - -// ============== AUDIO CONTROLS ============== - -function toggleAudio() { - if (isAudioPlaying) { - stopAudio(); - } else { - startAudio(); - } -} - -function startAudio() { - const frequency = parseFloat(document.getElementById('audioFrequency').value); - const modulation = document.getElementById('audioModulation').value; - const squelch = parseInt(document.getElementById('audioSquelch').value); - const gain = parseInt(document.getElementById('audioGain').value); - const device = getSelectedDevice(); - - if (isNaN(frequency) || frequency <= 0) { - if (typeof showNotification === 'function') { - showNotification('Audio Error', 'Invalid frequency'); - } - return; - } - - // Check if device is in use - if (typeof getDeviceInUseBy === 'function') { - const usedBy = getDeviceInUseBy(device); - if (usedBy && usedBy !== 'audio') { - if (typeof showNotification === 'function') { - showNotification('SDR In Use', `Device ${device} is being used by ${usedBy.toUpperCase()}.`); - } - return; - } - } - - document.getElementById('audioStatus').textContent = 'STARTING...'; - document.getElementById('audioStatus').style.color = 'var(--accent-orange)'; - - // Use direct streaming - no Icecast needed - if (typeof reserveDevice === 'function') reserveDevice(device, 'audio'); - isAudioPlaying = true; - - // Build direct stream URL with parameters - const streamUrl = `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`; - console.log('Connecting to direct stream:', streamUrl); - - // Start browser audio playback - const audioPlayer = document.getElementById('audioPlayer'); - audioPlayer.src = streamUrl; - audioPlayer.volume = document.getElementById('audioVolume').value / 100; - - initAudioVisualizer(); - - audioPlayer.onplaying = () => { - document.getElementById('audioStatus').textContent = 'STREAMING'; - document.getElementById('audioStatus').style.color = 'var(--accent-green)'; - }; - - audioPlayer.onerror = (e) => { - console.error('Audio player error:', e); - document.getElementById('audioStatus').textContent = 'ERROR'; - document.getElementById('audioStatus').style.color = 'var(--accent-red)'; - if (typeof showNotification === 'function') { - showNotification('Audio Error', 'Stream error - check SDR connection'); - } - }; - - audioPlayer.play().catch(e => { - console.warn('Audio autoplay blocked:', e); - if (typeof showNotification === 'function') { - showNotification('Audio Ready', 'Click Play button again if audio does not start'); - } - }); - - document.getElementById('audioStartBtn').innerHTML = Icons.stop('icon--sm') + ' Stop Audio'; - document.getElementById('audioStartBtn').classList.add('active'); - document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz (' + modulation.toUpperCase() + ')'; - document.getElementById('audioDeviceStatus').textContent = 'SDR ' + device; - - if (typeof showNotification === 'function') { - showNotification('Audio Started', `Streaming ${frequency} MHz to browser`); - } -} - -async function stopAudio() { - stopAudioVisualizer(); - - const audioPlayer = document.getElementById('audioPlayer'); - if (audioPlayer) { - audioPlayer.pause(); - audioPlayer.src = ''; - } - - try { - await fetch('/listening/audio/stop', { method: 'POST' }); - if (typeof releaseDevice === 'function') releaseDevice('audio'); - isAudioPlaying = false; - document.getElementById('audioStartBtn').innerHTML = Icons.play('icon--sm') + ' Play Audio'; - document.getElementById('audioStartBtn').classList.remove('active'); - document.getElementById('audioStatus').textContent = 'STOPPED'; - document.getElementById('audioStatus').style.color = 'var(--text-muted)'; - document.getElementById('audioDeviceStatus').textContent = '--'; - } catch (e) { - console.error('Error stopping audio:', e); - } -} - -function updateAudioVolume() { - const audioPlayer = document.getElementById('audioPlayer'); - if (audioPlayer) { - audioPlayer.volume = document.getElementById('audioVolume').value / 100; - } -} - -function audioFreqUp() { - const input = document.getElementById('audioFrequency'); - const mod = document.getElementById('audioModulation').value; - const step = (mod === 'wfm') ? 0.2 : 0.025; - input.value = (parseFloat(input.value) + step).toFixed(2); - if (isAudioPlaying) { - tuneAudioFrequency(parseFloat(input.value)); - } -} - -function audioFreqDown() { - const input = document.getElementById('audioFrequency'); - const mod = document.getElementById('audioModulation').value; - const step = (mod === 'wfm') ? 0.2 : 0.025; - input.value = (parseFloat(input.value) - step).toFixed(2); - if (isAudioPlaying) { - tuneAudioFrequency(parseFloat(input.value)); - } -} - -function tuneAudioFrequency(frequency) { - fetch('/listening/audio/tune', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ frequency: frequency }) - }) - .then(r => r.json()) - .then(data => { - if (data.status === 'tuned') { - document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz'; - } - }) - .catch(() => { - stopAudio(); - setTimeout(startAudio, 300); - }); -} - -async function tuneToFrequency(freq, mod) { - try { - // Stop scanner if running - if (isScannerRunning) { - stopScanner(); - await new Promise(resolve => setTimeout(resolve, 300)); - } - - // Update frequency input - const freqInput = document.getElementById('radioScanStart'); - if (freqInput) { - freqInput.value = freq.toFixed(1); - } - - // Update modulation if provided - if (mod) { - setModulation(mod); - } - - // Update tuning dial (silent to avoid duplicate events) - const mainTuningDial = document.getElementById('mainTuningDial'); - if (mainTuningDial && mainTuningDial._dial) { - mainTuningDial._dial.setValue(freq, true); - } - - // Update frequency display - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) { - mainFreq.textContent = freq.toFixed(3); - } - - // Start listening immediately - await startDirectListenImmediate(); - - if (typeof showNotification === 'function') { - showNotification('Tuned', `Now listening to ${freq.toFixed(3)} MHz (${(mod || currentModulation).toUpperCase()})`); - } - } catch (err) { - console.error('Error tuning to frequency:', err); - if (typeof showNotification === 'function') { - showNotification('Tune Error', 'Failed to tune to frequency: ' + err.message); - } - } -} - -// ============== AUDIO VISUALIZER ============== - -function initAudioVisualizer() { - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (!audioPlayer) { - console.warn('[VISUALIZER] No audio player found'); - return; - } - - console.log('[VISUALIZER] Initializing with audio player, src:', audioPlayer.src); - - if (!visualizerContext) { - visualizerContext = new (window.AudioContext || window.webkitAudioContext)(); - console.log('[VISUALIZER] Created audio context'); - } - - if (visualizerContext.state === 'suspended') { - console.log('[VISUALIZER] Resuming suspended audio context'); - visualizerContext.resume(); - } - - if (!visualizerSource) { - try { - visualizerSource = visualizerContext.createMediaElementSource(audioPlayer); - visualizerAnalyser = visualizerContext.createAnalyser(); - visualizerAnalyser.fftSize = 256; - visualizerAnalyser.smoothingTimeConstant = 0.7; - - visualizerSource.connect(visualizerAnalyser); - visualizerAnalyser.connect(visualizerContext.destination); - console.log('[VISUALIZER] Audio source and analyser connected'); - } catch (e) { - console.error('[VISUALIZER] Could not create audio source:', e); - // Try to continue anyway if analyser exists - if (!visualizerAnalyser) return; - } - } else { - console.log('[VISUALIZER] Reusing existing audio source'); - } - - const container = document.getElementById('audioVisualizerContainer'); - if (container) container.style.display = 'block'; - - // Start the visualization loop - if (!visualizerAnimationId) { - console.log('[VISUALIZER] Starting draw loop'); - drawAudioVisualizer(); - } else { - console.log('[VISUALIZER] Draw loop already running'); - } -} - -function drawAudioVisualizer() { - if (!visualizerAnalyser) { - console.warn('[VISUALIZER] No analyser available'); - return; - } - - const canvas = document.getElementById('audioSpectrumCanvas'); - const ctx = canvas ? canvas.getContext('2d') : null; - const bufferLength = visualizerAnalyser.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - - function draw() { - visualizerAnimationId = requestAnimationFrame(draw); - - visualizerAnalyser.getByteFrequencyData(dataArray); - - let sum = 0; - for (let i = 0; i < bufferLength; i++) { - sum += dataArray[i]; - } - const average = sum / bufferLength; - const levelPercent = (average / 255) * 100; - - // Feed audio level to synthesizer visualization during direct listening - if (isDirectListening || isScannerRunning) { - // Scale 0-255 average to 0-3000 range (matching SSE scan_update levels) - currentSignalLevel = (average / 255) * 3000; - } - - if (levelPercent > peakLevel) { - peakLevel = levelPercent; - } else { - peakLevel *= peakDecay; - } - - const meterFill = document.getElementById('audioSignalMeter'); - const meterPeak = document.getElementById('audioSignalPeak'); - const meterValue = document.getElementById('audioSignalValue'); - - if (meterFill) meterFill.style.width = levelPercent + '%'; - if (meterPeak) meterPeak.style.left = Math.min(peakLevel, 100) + '%'; - - const db = average > 0 ? Math.round(20 * Math.log10(average / 255)) : -60; - if (meterValue) meterValue.textContent = db + ' dB'; - - // Only draw spectrum if canvas exists - if (ctx && canvas) { - ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - const barWidth = canvas.width / bufferLength * 2.5; - let x = 0; - - for (let i = 0; i < bufferLength; i++) { - const barHeight = (dataArray[i] / 255) * canvas.height; - const hue = 200 - (i / bufferLength) * 60; - const lightness = 40 + (dataArray[i] / 255) * 30; - ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`; - ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight); - x += barWidth; - } - - ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; - ctx.font = '8px JetBrains Mono'; - ctx.fillText('0', 2, canvas.height - 2); - ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2); - ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2); - } - } - - draw(); -} - -function stopAudioVisualizer() { - if (visualizerAnimationId) { - cancelAnimationFrame(visualizerAnimationId); - visualizerAnimationId = null; - } - - const meterFill = document.getElementById('audioSignalMeter'); - const meterPeak = document.getElementById('audioSignalPeak'); - const meterValue = document.getElementById('audioSignalValue'); - - if (meterFill) meterFill.style.width = '0%'; - if (meterPeak) meterPeak.style.left = '0%'; - if (meterValue) meterValue.textContent = '-∞ dB'; - - peakLevel = 0; - - const container = document.getElementById('audioVisualizerContainer'); - if (container) container.style.display = 'none'; -} - -// ============== RADIO KNOB CONTROLS ============== - -/** - * Update scanner config on the backend (for live updates while scanning) - */ -function updateScannerConfig(config) { - if (!isScannerRunning) return; - fetch('/listening/scanner/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }).catch(() => {}); -} - -/** - * Initialize radio knob controls and wire them to scanner parameters - */ -function initRadioKnobControls() { - // Squelch knob - const squelchKnob = document.getElementById('radioSquelchKnob'); - if (squelchKnob) { - squelchKnob.addEventListener('knobchange', function(e) { - const value = Math.round(e.detail.value); - const valueDisplay = document.getElementById('radioSquelchValue'); - if (valueDisplay) valueDisplay.textContent = value; - // Sync with scanner - updateScannerConfig({ squelch: value }); - // Restart stream if direct listening (squelch requires restart) - if (isDirectListening) { - startDirectListen(); - } - }); - } - - // Gain knob - const gainKnob = document.getElementById('radioGainKnob'); - if (gainKnob) { - gainKnob.addEventListener('knobchange', function(e) { - const value = Math.round(e.detail.value); - const valueDisplay = document.getElementById('radioGainValue'); - if (valueDisplay) valueDisplay.textContent = value; - // Sync with scanner - updateScannerConfig({ gain: value }); - // Restart stream if direct listening (gain requires restart) - if (isDirectListening) { - startDirectListen(); - } - }); - } - - // Volume knob - controls scanner audio player volume - const volumeKnob = document.getElementById('radioVolumeKnob'); - if (volumeKnob) { - volumeKnob.addEventListener('knobchange', function(e) { - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (audioPlayer) { - audioPlayer.volume = e.detail.value / 100; - console.log('[VOLUME] Set to', Math.round(e.detail.value) + '%'); - } - // Update knob value display - const valueDisplay = document.getElementById('radioVolumeValue'); - if (valueDisplay) valueDisplay.textContent = Math.round(e.detail.value); - }); - } - - // Main Tuning dial - updates frequency display and inputs - const mainTuningDial = document.getElementById('mainTuningDial'); - if (mainTuningDial) { - mainTuningDial.addEventListener('knobchange', function(e) { - const freq = e.detail.value; - // Update main frequency display - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) { - mainFreq.textContent = freq.toFixed(3); - } - // Update radio scan start input - const startFreqInput = document.getElementById('radioScanStart'); - if (startFreqInput) { - startFreqInput.value = freq.toFixed(1); - } - // Update sidebar frequency input - const sidebarFreq = document.getElementById('audioFrequency'); - if (sidebarFreq) { - sidebarFreq.value = freq.toFixed(3); - } - // If currently listening, retune to new frequency - if (isDirectListening) { - startDirectListen(); - } - }); - } - - // Legacy tuning dial support - const tuningDial = document.getElementById('tuningDial'); - if (tuningDial) { - tuningDial.addEventListener('knobchange', function(e) { - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.textContent = e.detail.value.toFixed(3); - const startFreqInput = document.getElementById('radioScanStart'); - if (startFreqInput) startFreqInput.value = e.detail.value.toFixed(1); - // If currently listening, retune to new frequency - if (isDirectListening) { - startDirectListen(); - } - }); - } - - // Sync radio scan range inputs with sidebar - const radioScanStart = document.getElementById('radioScanStart'); - const radioScanEnd = document.getElementById('radioScanEnd'); - - if (radioScanStart) { - radioScanStart.addEventListener('change', function() { - const sidebarStart = document.getElementById('scanStartFreq'); - if (sidebarStart) sidebarStart.value = this.value; - // Restart stream if direct listening - if (isDirectListening) { - startDirectListen(); - } - }); - } - - if (radioScanEnd) { - radioScanEnd.addEventListener('change', function() { - const sidebarEnd = document.getElementById('scanEndFreq'); - if (sidebarEnd) sidebarEnd.value = this.value; - }); - } -} - -/** - * Set modulation mode (called from HTML onclick) - */ -function setModulation(mod) { - // Update sidebar select - const modSelect = document.getElementById('scanModulation'); - if (modSelect) modSelect.value = mod; - - // Update audio modulation select - const audioMod = document.getElementById('audioModulation'); - if (audioMod) audioMod.value = mod; - - // Update button states in radio panel - document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.mod === mod); - }); - - // Update main display badge - const mainBadge = document.getElementById('mainScannerMod'); - if (mainBadge) mainBadge.textContent = mod.toUpperCase(); -} - -/** - * Set band preset (called from HTML onclick) - */ -function setBand(band) { - const preset = scannerPresets[band]; - if (!preset) return; - - // Update button states - document.querySelectorAll('#bandBtnBank .radio-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.band === band); - }); - - // Update sidebar frequency inputs - const sidebarStart = document.getElementById('scanStartFreq'); - const sidebarEnd = document.getElementById('scanEndFreq'); - if (sidebarStart) sidebarStart.value = preset.start; - if (sidebarEnd) sidebarEnd.value = preset.end; - - // Update radio panel frequency inputs - const radioStart = document.getElementById('radioScanStart'); - const radioEnd = document.getElementById('radioScanEnd'); - if (radioStart) radioStart.value = preset.start; - if (radioEnd) radioEnd.value = preset.end; - - // Update tuning dial range and value (silent to avoid triggering restart) - const tuningDial = document.getElementById('tuningDial'); - if (tuningDial && tuningDial._dial) { - tuningDial._dial.min = preset.start; - tuningDial._dial.max = preset.end; - tuningDial._dial.setValue(preset.start, true); - } - - // Update main frequency display - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.textContent = preset.start.toFixed(3); - - // Update modulation - setModulation(preset.mod); - - // Update main range display if scanning - const rangeStart = document.getElementById('mainRangeStart'); - const rangeEnd = document.getElementById('mainRangeEnd'); - if (rangeStart) rangeStart.textContent = preset.start; - if (rangeEnd) rangeEnd.textContent = preset.end; - - // Store for scanner use - scannerStartFreq = preset.start; - scannerEndFreq = preset.end; -} - -// ============== SYNTHESIZER VISUALIZATION ============== - -let synthAnimationId = null; -let synthCanvas = null; -let synthCtx = null; -let synthBars = []; -const SYNTH_BAR_COUNT = 32; - -function initSynthesizer() { - synthCanvas = document.getElementById('synthesizerCanvas'); - if (!synthCanvas) return; - - // Set canvas size - const rect = synthCanvas.parentElement.getBoundingClientRect(); - synthCanvas.width = rect.width - 20; - synthCanvas.height = 60; - - synthCtx = synthCanvas.getContext('2d'); - - // Initialize bar heights - for (let i = 0; i < SYNTH_BAR_COUNT; i++) { - synthBars[i] = { height: 0, targetHeight: 0, velocity: 0 }; - } - - drawSynthesizer(); -} - -// Debug: log signal level periodically -let lastSynthDebugLog = 0; - -function drawSynthesizer() { - if (!synthCtx || !synthCanvas) return; - - const width = synthCanvas.width; - const height = synthCanvas.height; - const barWidth = (width / SYNTH_BAR_COUNT) - 2; - - // Clear canvas - synthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; - synthCtx.fillRect(0, 0, width, height); - - // Determine activity level based on actual signal level - let activityLevel = 0; - let signalIntensity = 0; - - // Debug logging every 2 seconds - const now = Date.now(); - if (now - lastSynthDebugLog > 2000) { - console.log('[SYNTH] State:', { - isScannerRunning, - isDirectListening, - scannerSignalActive, - currentSignalLevel, - visualizerAnalyser: !!visualizerAnalyser - }); - lastSynthDebugLog = now; - } - - if (isScannerRunning && !isScannerPaused) { - // Use actual signal level data (0-5000 range, normalize to 0-1) - signalIntensity = Math.min(1, currentSignalLevel / 3000); - // Base activity when scanning, boosted by actual signal strength - activityLevel = 0.15 + (signalIntensity * 0.85); - if (scannerSignalActive) { - activityLevel = Math.max(activityLevel, 0.7); - } - } else if (isDirectListening) { - // For direct listening, use signal level if available - signalIntensity = Math.min(1, currentSignalLevel / 3000); - activityLevel = 0.2 + (signalIntensity * 0.8); - } - - // Update bar targets - for (let i = 0; i < SYNTH_BAR_COUNT; i++) { - if (activityLevel > 0) { - // Create wave-like pattern modulated by actual signal strength - const time = Date.now() / 200; - // Multiple wave frequencies for more organic feel - const wave1 = Math.sin(time + (i * 0.3)) * 0.2; - const wave2 = Math.sin(time * 1.7 + (i * 0.5)) * 0.15; - // Less randomness when signal is weak, more when strong - const randomAmount = 0.1 + (signalIntensity * 0.3); - const random = (Math.random() - 0.5) * randomAmount; - // Center bars tend to be taller (frequency spectrum shape) - const centerBoost = 1 - Math.abs((i - SYNTH_BAR_COUNT / 2) / (SYNTH_BAR_COUNT / 2)) * 0.4; - // Combine all factors with signal-driven amplitude - const baseHeight = 0.15 + (signalIntensity * 0.5); - synthBars[i].targetHeight = (baseHeight + wave1 + wave2 + random) * activityLevel * centerBoost * height; - } else { - // Idle state - minimal activity - synthBars[i].targetHeight = (Math.sin((Date.now() / 500) + (i * 0.5)) * 0.1 + 0.1) * height * 0.3; - } - - // Smooth animation - faster response when signal changes - const springStrength = signalIntensity > 0.3 ? 0.15 : 0.1; - const diff = synthBars[i].targetHeight - synthBars[i].height; - synthBars[i].velocity += diff * springStrength; - synthBars[i].velocity *= 0.8; - synthBars[i].height += synthBars[i].velocity; - synthBars[i].height = Math.max(2, Math.min(height - 4, synthBars[i].height)); - } - - // Draw bars - for (let i = 0; i < SYNTH_BAR_COUNT; i++) { - const x = i * (barWidth + 2) + 1; - const barHeight = synthBars[i].height; - const y = (height - barHeight) / 2; - - // Color gradient based on height and state - let hue, saturation, lightness; - if (scannerSignalActive) { - hue = 120; // Green for signal - saturation = 80; - lightness = 40 + (barHeight / height) * 30; - } else if (isScannerRunning || isDirectListening) { - hue = 190 + (i / SYNTH_BAR_COUNT) * 30; // Cyan to blue - saturation = 80; - lightness = 35 + (barHeight / height) * 25; - } else { - hue = 200; - saturation = 50; - lightness = 25 + (barHeight / height) * 15; - } - - const gradient = synthCtx.createLinearGradient(x, y, x, y + barHeight); - gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`); - gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`); - gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`); - - synthCtx.fillStyle = gradient; - synthCtx.fillRect(x, y, barWidth, barHeight); - - // Add glow effect for active bars - if (barHeight > height * 0.5 && activityLevel > 0.5) { - synthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`; - synthCtx.shadowBlur = 8; - synthCtx.fillRect(x, y, barWidth, barHeight); - synthCtx.shadowBlur = 0; - } - } - - // Draw center line - synthCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)'; - synthCtx.lineWidth = 1; - synthCtx.beginPath(); - synthCtx.moveTo(0, height / 2); - synthCtx.lineTo(width, height / 2); - synthCtx.stroke(); - - // Debug: show signal level value - if (isScannerRunning || isDirectListening) { - synthCtx.fillStyle = 'rgba(255, 255, 255, 0.5)'; - synthCtx.font = '9px monospace'; - synthCtx.fillText(`lvl:${Math.round(currentSignalLevel)}`, 4, 10); - } - - synthAnimationId = requestAnimationFrame(drawSynthesizer); -} - -function stopSynthesizer() { - if (synthAnimationId) { - cancelAnimationFrame(synthAnimationId); - synthAnimationId = null; - } -} - -// ============== INITIALIZATION ============== - -/** - * Get the audio stream URL with parameters - * Streams directly from Flask - no Icecast needed - */ -function getStreamUrl(freq, mod) { - const frequency = freq || parseFloat(document.getElementById('radioScanStart')?.value) || 118.0; - const modulation = mod || currentModulation || 'am'; - const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; - const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; - return `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`; -} - -function initListeningPost() { - checkScannerTools(); - checkAudioTools(); - - // WebSocket audio disabled for now - using HTTP streaming - // initWebSocketAudio(); - - // Initialize synthesizer visualization - initSynthesizer(); - - // Initialize radio knobs if the component is available - if (typeof initRadioKnobs === 'function') { - initRadioKnobs(); - } - - // Connect radio knobs to scanner controls - initRadioKnobControls(); - - // Step dropdown - sync with scanner when changed - const stepSelect = document.getElementById('radioScanStep'); - if (stepSelect) { - stepSelect.addEventListener('change', function() { - const step = parseFloat(this.value); - console.log('[SCANNER] Step changed to:', step, 'kHz'); - updateScannerConfig({ step: step }); - }); - } - - // Dwell dropdown - sync with scanner when changed - const dwellSelect = document.getElementById('radioScanDwell'); - if (dwellSelect) { - dwellSelect.addEventListener('change', function() { - const dwell = parseInt(this.value); - console.log('[SCANNER] Dwell changed to:', dwell, 's'); - updateScannerConfig({ dwell_time: dwell }); - }); - } - - // Set up audio player error handling - const audioPlayer = document.getElementById('audioPlayer'); - if (audioPlayer) { - audioPlayer.addEventListener('error', function(e) { - console.warn('Audio player error:', e); - if (isAudioPlaying && audioReconnectAttempts < MAX_AUDIO_RECONNECT) { - audioReconnectAttempts++; - setTimeout(() => { - audioPlayer.src = getStreamUrl(); - audioPlayer.play().catch(() => {}); - }, 500); - } - }); - - audioPlayer.addEventListener('stalled', function() { - if (isAudioPlaying) { - audioPlayer.load(); - audioPlayer.play().catch(() => {}); - } - }); - - audioPlayer.addEventListener('playing', function() { - audioReconnectAttempts = 0; - }); - } - - // Keyboard controls for frequency tuning - document.addEventListener('keydown', function(e) { - // Only active in listening mode - if (typeof currentMode !== 'undefined' && currentMode !== 'listening') { - return; - } - - // Don't intercept if user is typing in an input - const activeEl = document.activeElement; - if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT')) { - return; - } - - // Arrow keys for tuning - // Up/Down: fine tuning (Shift for ultra-fine) - // Left/Right: coarse tuning (Shift for very coarse) - let delta = 0; - switch (e.key) { - case 'ArrowUp': - delta = e.shiftKey ? 0.005 : 0.05; - break; - case 'ArrowDown': - delta = e.shiftKey ? -0.005 : -0.05; - break; - case 'ArrowRight': - delta = e.shiftKey ? 1 : 0.1; - break; - case 'ArrowLeft': - delta = e.shiftKey ? -1 : -0.1; - break; - default: - return; // Not a tuning key - } - - e.preventDefault(); - tuneFreq(delta); - }); - - // Check if we arrived from Spy Stations with a tune request - checkIncomingTuneRequest(); -} - -/** - * Check for incoming tune request from Spy Stations or other pages - */ -function checkIncomingTuneRequest() { - const tuneFreq = sessionStorage.getItem('tuneFrequency'); - const tuneMode = sessionStorage.getItem('tuneMode'); - - if (tuneFreq) { - // Clear the session storage first - sessionStorage.removeItem('tuneFrequency'); - sessionStorage.removeItem('tuneMode'); - - // Parse and validate frequency - const freq = parseFloat(tuneFreq); - if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) { - console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default'); - - // Determine modulation (default to USB for HF/number stations) - const mod = tuneMode || (freq < 30 ? 'usb' : 'am'); - - // Use quickTune to set frequency and modulation - quickTune(freq, mod); - - // Show notification - if (typeof showNotification === 'function') { - showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode'); - } - } - } -} - -// Initialize when DOM is ready -document.addEventListener('DOMContentLoaded', initListeningPost); - -// ============== UNIFIED RADIO CONTROLS ============== - -/** - * Toggle direct listen mode (tune to start frequency and listen) - */ -function toggleDirectListen() { - console.log('[LISTEN] toggleDirectListen called, isDirectListening:', isDirectListening); - if (isDirectListening) { - stopDirectListen(); - } else { - // First press - start immediately, don't debounce - startDirectListenImmediate(); - } -} - -// Debounce for startDirectListen -let listenDebounceTimer = null; -// Flag to prevent overlapping restart attempts -let isRestarting = false; -// Flag indicating another restart is needed after current one finishes -let restartPending = false; -// Debounce for frequency tuning (user might be scrolling through) -// Needs to be long enough for SDR to fully release between restarts -const TUNE_DEBOUNCE_MS = 600; - -/** - * Start direct listening - debounced for frequency changes - */ -function startDirectListen() { - if (listenDebounceTimer) { - clearTimeout(listenDebounceTimer); - } - listenDebounceTimer = setTimeout(async () => { - // If already restarting, mark that we need another restart when done - if (isRestarting) { - console.log('[LISTEN] Restart in progress, will retry after'); - restartPending = true; - return; - } - - await _startDirectListenInternal(); - - // If another restart was requested during this one, do it now - while (restartPending) { - restartPending = false; - console.log('[LISTEN] Processing pending restart'); - await _startDirectListenInternal(); - } - }, TUNE_DEBOUNCE_MS); -} - -/** - * Start listening immediately (no debounce) - for button press - */ -async function startDirectListenImmediate() { - if (listenDebounceTimer) { - clearTimeout(listenDebounceTimer); - listenDebounceTimer = null; - } - restartPending = false; // Clear any pending - if (isRestarting) { - console.log('[LISTEN] Waiting for current restart to finish...'); - // Wait for current restart to complete (max 5 seconds) - let waitCount = 0; - while (isRestarting && waitCount < 50) { - await new Promise(r => setTimeout(r, 100)); - waitCount++; - } - } - await _startDirectListenInternal(); -} - -// ============== WEBSOCKET AUDIO ============== - -/** - * Initialize WebSocket audio connection - */ -function initWebSocketAudio() { - if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) { - return audioWebSocket; - } - - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/audio`; - - console.log('[WS-AUDIO] Connecting to:', wsUrl); - audioWebSocket = new WebSocket(wsUrl); - audioWebSocket.binaryType = 'arraybuffer'; - - audioWebSocket.onopen = () => { - console.log('[WS-AUDIO] Connected'); - isWebSocketAudio = true; - }; - - audioWebSocket.onclose = () => { - console.log('[WS-AUDIO] Disconnected'); - isWebSocketAudio = false; - audioWebSocket = null; - }; - - audioWebSocket.onerror = (e) => { - console.error('[WS-AUDIO] Error:', e); - isWebSocketAudio = false; - }; - - audioWebSocket.onmessage = (event) => { - if (typeof event.data === 'string') { - // JSON message (status updates) - try { - const msg = JSON.parse(event.data); - console.log('[WS-AUDIO] Status:', msg); - if (msg.status === 'error') { - addScannerLogEntry('Audio error: ' + msg.message, '', 'error'); - } - } catch (e) {} - } else { - // Binary data (audio) - handleWebSocketAudioData(event.data); - } - }; - - return audioWebSocket; -} - -/** - * Handle incoming WebSocket audio data - */ -function handleWebSocketAudioData(data) { - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (!audioPlayer) return; - - // Use MediaSource API to stream audio - if (!audioPlayer.msSource) { - setupMediaSource(audioPlayer); - } - - if (audioPlayer.sourceBuffer && !audioPlayer.sourceBuffer.updating) { - try { - audioPlayer.sourceBuffer.appendBuffer(new Uint8Array(data)); - } catch (e) { - // Buffer full or other error, skip this chunk - } - } else { - // Queue data for later - audioQueue.push(new Uint8Array(data)); - if (audioQueue.length > 50) audioQueue.shift(); // Prevent memory buildup - } -} - -/** - * Setup MediaSource for streaming audio - */ -function setupMediaSource(audioPlayer) { - if (!window.MediaSource) { - console.warn('[WS-AUDIO] MediaSource not supported'); - return; - } - - const mediaSource = new MediaSource(); - audioPlayer.src = URL.createObjectURL(mediaSource); - audioPlayer.msSource = mediaSource; - - mediaSource.addEventListener('sourceopen', () => { - try { - const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg'); - audioPlayer.sourceBuffer = sourceBuffer; - - sourceBuffer.addEventListener('updateend', () => { - // Process queued data - if (audioQueue.length > 0 && !sourceBuffer.updating) { - try { - sourceBuffer.appendBuffer(audioQueue.shift()); - } catch (e) {} - } - }); - } catch (e) { - console.error('[WS-AUDIO] Failed to create source buffer:', e); - } - }); -} - -/** - * Send command over WebSocket - */ -function sendWebSocketCommand(cmd, config = {}) { - if (!audioWebSocket || audioWebSocket.readyState !== WebSocket.OPEN) { - initWebSocketAudio(); - // Wait for connection and retry - setTimeout(() => sendWebSocketCommand(cmd, config), 500); - return; - } - - audioWebSocket.send(JSON.stringify({ cmd, config })); -} - -async function _startDirectListenInternal() { - console.log('[LISTEN] _startDirectListenInternal called'); - - // Prevent overlapping restarts - if (isRestarting) { - console.log('[LISTEN] Already restarting, skipping'); - return; - } - isRestarting = true; - - try { - if (isScannerRunning) { - stopScanner(); - } - - const freqInput = document.getElementById('radioScanStart'); - const freq = freqInput ? parseFloat(freqInput.value) : 118.0; - const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; - const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; - - console.log('[LISTEN] Tuning to:', freq, 'MHz', currentModulation); - - const listenBtn = document.getElementById('radioListenBtn'); - if (listenBtn) { - listenBtn.innerHTML = Icons.loader('icon--sm') + ' TUNING...'; - listenBtn.style.background = 'var(--accent-orange)'; - listenBtn.style.borderColor = 'var(--accent-orange)'; - } - - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (!audioPlayer) { - addScannerLogEntry('Audio player not found', '', 'error'); - updateDirectListenUI(false); - return; - } - - // Fully reset audio element to clean state - audioPlayer.oncanplay = null; // Remove old handler - try { - audioPlayer.pause(); - } catch (e) {} - audioPlayer.removeAttribute('src'); - audioPlayer.load(); // Reset the element - - // Start audio on backend (it handles stopping old stream) - const response = await fetch('/listening/audio/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - frequency: freq, - modulation: currentModulation, - squelch: squelch, - gain: gain - }) - }); - - const result = await response.json(); - console.log('[LISTEN] Backend:', result.status); - - if (result.status !== 'started') { - console.error('[LISTEN] Failed:', result.message); - addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error'); - isDirectListening = false; - updateDirectListenUI(false); - return; - } - - // Wait for stream to be ready (backend needs time after restart) - await new Promise(r => setTimeout(r, 300)); - - // Connect to new stream - const streamUrl = `/listening/audio/stream?t=${Date.now()}`; - console.log('[LISTEN] Connecting to stream:', streamUrl); - audioPlayer.src = streamUrl; - - // Apply current volume from knob - const volumeKnob = document.getElementById('radioVolumeKnob'); - if (volumeKnob && volumeKnob._knob) { - audioPlayer.volume = volumeKnob._knob.getValue() / 100; - } else if (volumeKnob) { - const knobValue = parseFloat(volumeKnob.dataset.value) || 80; - audioPlayer.volume = knobValue / 100; - } - - // Wait for audio to be ready then play - audioPlayer.oncanplay = () => { - console.log('[LISTEN] Audio can play'); - audioPlayer.play().catch(e => console.warn('[LISTEN] Autoplay blocked:', e)); - }; - - // Also try to play immediately (some browsers need this) - audioPlayer.play().catch(e => { - console.log('[LISTEN] Initial play blocked, waiting for canplay'); - }); - - // Initialize audio visualizer to feed signal levels to synthesizer - initAudioVisualizer(); - - isDirectListening = true; - updateDirectListenUI(true, freq); - addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal'); - - } catch (e) { - console.error('[LISTEN] Error:', e); - addScannerLogEntry('Error: ' + e.message, '', 'error'); - isDirectListening = false; - updateDirectListenUI(false); - } finally { - isRestarting = false; - } -} - -/** - * Stop direct listening - */ -function stopDirectListen() { - console.log('[LISTEN] Stopping'); - - // Clear all pending state - if (listenDebounceTimer) { - clearTimeout(listenDebounceTimer); - listenDebounceTimer = null; - } - restartPending = false; - - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (audioPlayer) { - audioPlayer.pause(); - // Clear MediaSource if using WebSocket - if (audioPlayer.msSource) { - try { - audioPlayer.msSource.endOfStream(); - } catch (e) {} - audioPlayer.msSource = null; - audioPlayer.sourceBuffer = null; - } - audioPlayer.src = ''; - } - audioQueue = []; - - // Stop via WebSocket if connected - if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) { - sendWebSocketCommand('stop'); - } - - // Also stop via HTTP (fallback) - fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {}); - - isDirectListening = false; - currentSignalLevel = 0; - updateDirectListenUI(false); - addScannerLogEntry('Listening stopped'); -} - -/** - * Update UI for direct listen mode - */ -function updateDirectListenUI(isPlaying, freq) { - const listenBtn = document.getElementById('radioListenBtn'); - const statusLabel = document.getElementById('mainScannerModeLabel'); - const freqDisplay = document.getElementById('mainScannerFreq'); - const quickStatus = document.getElementById('lpQuickStatus'); - const quickFreq = document.getElementById('lpQuickFreq'); - - if (listenBtn) { - if (isPlaying) { - listenBtn.innerHTML = Icons.stop('icon--sm') + ' STOP'; - listenBtn.style.background = 'var(--accent-red)'; - listenBtn.style.borderColor = 'var(--accent-red)'; - } else { - listenBtn.innerHTML = Icons.headphones('icon--sm') + ' LISTEN'; - listenBtn.style.background = 'var(--accent-purple)'; - listenBtn.style.borderColor = 'var(--accent-purple)'; - } - } - - if (statusLabel) { - statusLabel.textContent = isPlaying ? 'LISTENING' : 'STOPPED'; - statusLabel.style.color = isPlaying ? 'var(--accent-green)' : 'var(--text-muted)'; - } - - if (freqDisplay && freq) { - freqDisplay.textContent = freq.toFixed(3); - } - - if (quickStatus) { - quickStatus.textContent = isPlaying ? 'LISTENING' : 'IDLE'; - quickStatus.style.color = isPlaying ? 'var(--accent-green)' : 'var(--accent-cyan)'; - } - - if (quickFreq && freq) { - quickFreq.textContent = freq.toFixed(3) + ' MHz'; - } -} - -/** - * Tune frequency by delta - */ -function tuneFreq(delta) { - const freqInput = document.getElementById('radioScanStart'); - if (freqInput) { - let newFreq = parseFloat(freqInput.value) + delta; - // Round to 3 decimal places to avoid floating-point precision issues - newFreq = Math.round(newFreq * 1000) / 1000; - newFreq = Math.max(24, Math.min(1800, newFreq)); - freqInput.value = newFreq.toFixed(3); - - // Update display - const freqDisplay = document.getElementById('mainScannerFreq'); - if (freqDisplay) { - freqDisplay.textContent = newFreq.toFixed(3); - } - - // Update tuning dial position (silent to avoid duplicate restart) - const mainTuningDial = document.getElementById('mainTuningDial'); - if (mainTuningDial && mainTuningDial._dial) { - mainTuningDial._dial.setValue(newFreq, true); - } - - const quickFreq = document.getElementById('lpQuickFreq'); - if (quickFreq) { - quickFreq.textContent = newFreq.toFixed(3) + ' MHz'; - } - - // If currently listening, restart stream at new frequency - if (isDirectListening) { - startDirectListen(); - } - } -} - -/** - * Quick tune to a preset frequency - */ -function quickTune(freq, mod) { - // Update frequency inputs - const startInput = document.getElementById('radioScanStart'); - if (startInput) { - startInput.value = freq; - } - - // Update modulation (don't trigger auto-restart here, we'll handle it below) - if (mod) { - currentModulation = mod; - // Update modulation UI without triggering restart - document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.mod === mod); - }); - const badge = document.getElementById('mainScannerMod'); - if (badge) { - const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' }; - badge.textContent = modLabels[mod] || mod.toUpperCase(); - } - } - - // Update display - const freqDisplay = document.getElementById('mainScannerFreq'); - if (freqDisplay) { - freqDisplay.textContent = freq.toFixed(3); - } - - // Update tuning dial position (silent to avoid duplicate restart) - const mainTuningDial = document.getElementById('mainTuningDial'); - if (mainTuningDial && mainTuningDial._dial) { - mainTuningDial._dial.setValue(freq, true); - } - - const quickFreq = document.getElementById('lpQuickFreq'); - if (quickFreq) { - quickFreq.textContent = freq.toFixed(3) + ' MHz'; - } - - addScannerLogEntry(`Quick tuned to ${freq.toFixed(3)} MHz (${mod.toUpperCase()})`); - - // If currently listening, restart immediately (this is a deliberate preset selection) - if (isDirectListening) { - startDirectListenImmediate(); - } -} - -/** - * Enhanced setModulation to also update currentModulation - * Uses immediate restart if currently listening - */ -const originalSetModulation = window.setModulation; -window.setModulation = function(mod) { - console.log('[MODULATION] Setting modulation to:', mod, 'isListening:', isDirectListening); - currentModulation = mod; - - // Update modulation button states - document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.mod === mod); - }); - - // Update badge - const badge = document.getElementById('mainScannerMod'); - if (badge) { - const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' }; - badge.textContent = modLabels[mod] || mod.toUpperCase(); - } - - // Update scanner modulation select if exists - const modSelect = document.getElementById('scannerModulation'); - if (modSelect) { - modSelect.value = mod; - } - - // Sync with scanner if running - updateScannerConfig({ modulation: mod }); - - // If currently listening, restart immediately (deliberate modulation change) - if (isDirectListening) { - console.log('[MODULATION] Restarting audio with new modulation:', mod); - startDirectListenImmediate(); - } else { - console.log('[MODULATION] Not listening, just updated UI'); - } -}; - -/** - * Update sidebar quick status - */ -function updateQuickStatus() { - const quickStatus = document.getElementById('lpQuickStatus'); - const quickFreq = document.getElementById('lpQuickFreq'); - const quickSignals = document.getElementById('lpQuickSignals'); - - if (quickStatus) { - if (isScannerRunning) { - quickStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; - quickStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; - } else if (isDirectListening) { - quickStatus.textContent = 'LISTENING'; - quickStatus.style.color = 'var(--accent-green)'; - } else { - quickStatus.textContent = 'IDLE'; - quickStatus.style.color = 'var(--accent-cyan)'; - } - } - - if (quickSignals) { - quickSignals.textContent = scannerSignalCount; - } -} - -// ============== SIDEBAR CONTROLS ============== - -// Frequency bookmarks stored in localStorage -let frequencyBookmarks = []; - -/** - * Load bookmarks from localStorage - */ -function loadFrequencyBookmarks() { - try { - const saved = localStorage.getItem('lpBookmarks'); - if (saved) { - frequencyBookmarks = JSON.parse(saved); - renderBookmarks(); - } - } catch (e) { - console.warn('Failed to load bookmarks:', e); - } -} - -/** - * Save bookmarks to localStorage - */ -function saveFrequencyBookmarks() { - try { - localStorage.setItem('lpBookmarks', JSON.stringify(frequencyBookmarks)); - } catch (e) { - console.warn('Failed to save bookmarks:', e); - } -} - -/** - * Add a frequency bookmark - */ -function addFrequencyBookmark() { - const input = document.getElementById('bookmarkFreqInput'); - if (!input) return; - - const freq = parseFloat(input.value); - if (isNaN(freq) || freq <= 0) { - if (typeof showNotification === 'function') { - showNotification('Invalid Frequency', 'Please enter a valid frequency'); - } - return; - } - - // Check for duplicates - if (frequencyBookmarks.some(b => Math.abs(b.freq - freq) < 0.001)) { - if (typeof showNotification === 'function') { - showNotification('Duplicate', 'This frequency is already bookmarked'); - } - return; - } - - frequencyBookmarks.push({ - freq: freq, - mod: currentModulation || 'am', - added: new Date().toISOString() - }); - - saveFrequencyBookmarks(); - renderBookmarks(); - input.value = ''; - - if (typeof showNotification === 'function') { - showNotification('Bookmark Added', `${freq.toFixed(3)} MHz saved`); - } -} - -/** - * Remove a bookmark by index - */ -function removeBookmark(index) { - frequencyBookmarks.splice(index, 1); - saveFrequencyBookmarks(); - renderBookmarks(); -} - -/** - * Render bookmarks list - */ -function renderBookmarks() { - const container = document.getElementById('bookmarksList'); - if (!container) return; - - if (frequencyBookmarks.length === 0) { - container.innerHTML = '
No bookmarks saved
'; - return; - } - - container.innerHTML = frequencyBookmarks.map((b, i) => ` -
- ${b.freq.toFixed(3)} MHz - ${b.mod.toUpperCase()} - -
- `).join(''); -} - - -/** - * Add a signal to the sidebar recent signals list - */ -function addSidebarRecentSignal(freq, mod) { - const container = document.getElementById('sidebarRecentSignals'); - if (!container) return; - - // Clear placeholder if present - if (container.innerHTML.includes('No signals yet')) { - container.innerHTML = ''; - } - - const timestamp = new Date().toLocaleTimeString(); - const signalDiv = document.createElement('div'); - signalDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 3px 6px; background: rgba(0,255,100,0.1); border-left: 2px solid var(--accent-green); margin-bottom: 2px; border-radius: 2px;'; - signalDiv.innerHTML = ` - ${freq.toFixed(3)} - ${timestamp} - `; - - container.insertBefore(signalDiv, container.firstChild); - - // Keep only last 10 signals - while (container.children.length > 10) { - container.removeChild(container.lastChild); - } -} - -// Load bookmarks on init -document.addEventListener('DOMContentLoaded', loadFrequencyBookmarks); - -/** - * Set listening post running state from external source (agent sync). - * Called by syncModeUI in agents.js when switching to an agent that already has scan running. - */ -function setListeningPostRunning(isRunning, agentId = null) { - console.log(`[ListeningPost] setListeningPostRunning: ${isRunning}, agent: ${agentId}`); - - isScannerRunning = isRunning; - - if (isRunning && agentId !== null && agentId !== 'local') { - // Agent has scan running - sync UI and start polling - listeningPostCurrentAgent = agentId; - - // Update main scan button (radioScanBtn is the actual ID) - const radioScanBtn = document.getElementById('radioScanBtn'); - if (radioScanBtn) { - radioScanBtn.innerHTML = 'STOP'; - radioScanBtn.style.background = 'var(--accent-red)'; - radioScanBtn.style.borderColor = 'var(--accent-red)'; - } - - // Update status display - updateScannerDisplay('SCANNING', 'var(--accent-green)'); - - // Disable listen button (can't stream audio from agent) - updateListenButtonState(true); - - // Start polling for agent data - startListeningPostPolling(); - } else if (!isRunning) { - // Not running - reset UI - listeningPostCurrentAgent = null; - - // Reset scan button - const radioScanBtn = document.getElementById('radioScanBtn'); - if (radioScanBtn) { - radioScanBtn.innerHTML = 'SCAN'; - radioScanBtn.style.background = ''; - radioScanBtn.style.borderColor = ''; - } - - // Update status - updateScannerDisplay('IDLE', 'var(--text-secondary)'); - - // Only re-enable listen button if we're in local mode - // (agent mode can't stream audio over HTTP) - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - updateListenButtonState(isAgentMode); - - // Clear polling - if (listeningPostPollTimer) { - clearInterval(listeningPostPollTimer); - listeningPostPollTimer = null; - } - } -} - -// Export for agent sync -window.setListeningPostRunning = setListeningPostRunning; -window.updateListenButtonState = updateListenButtonState; - -// Export functions for HTML onclick handlers -window.toggleDirectListen = toggleDirectListen; -window.startDirectListen = startDirectListen; -window.stopDirectListen = stopDirectListen; -window.toggleScanner = toggleScanner; -window.startScanner = startScanner; -window.stopScanner = stopScanner; -window.pauseScanner = pauseScanner; -window.skipSignal = skipSignal; -// Note: setModulation is already exported with enhancements above -window.setBand = setBand; -window.tuneFreq = tuneFreq; -window.quickTune = quickTune; -window.checkIncomingTuneRequest = checkIncomingTuneRequest; -window.addFrequencyBookmark = addFrequencyBookmark; -window.removeBookmark = removeBookmark; -window.tuneToFrequency = tuneToFrequency; -window.clearScannerLog = clearScannerLog; -window.exportScannerLog = exportScannerLog; - +/** + * Intercept - Listening Post Mode + * Frequency scanner and manual audio receiver + */ + +// ============== STATE ============== + +let isScannerRunning = false; +let isScannerPaused = false; +let scannerEventSource = null; +let scannerSignalCount = 0; +let scannerLogEntries = []; +let scannerFreqsScanned = 0; +let scannerCycles = 0; +let scannerStartFreq = 118; +let scannerEndFreq = 137; +let scannerSignalActive = false; + +// Audio state +let isAudioPlaying = false; +let audioToolsAvailable = { rtl_fm: false, ffmpeg: false }; +let audioReconnectAttempts = 0; +const MAX_AUDIO_RECONNECT = 3; + +// WebSocket audio state +let audioWebSocket = null; +let audioQueue = []; +let isWebSocketAudio = false; + +// Visualizer state +let visualizerContext = null; +let visualizerAnalyser = null; +let visualizerSource = null; +let visualizerAnimationId = null; +let peakLevel = 0; +let peakDecay = 0.95; + +// Signal level for synthesizer visualization +let currentSignalLevel = 0; +let signalLevelThreshold = 1000; + +// Track recent signal hits to prevent duplicates +let recentSignalHits = new Map(); + +// Direct listen state +let isDirectListening = false; +let currentModulation = 'am'; + +// Agent mode state +let listeningPostCurrentAgent = null; +let listeningPostPollTimer = null; + +// ============== PRESETS ============== + +const scannerPresets = { + fm: { start: 88, end: 108, step: 200, mod: 'wfm' }, + air: { start: 118, end: 137, step: 25, mod: 'am' }, + marine: { start: 156, end: 163, step: 25, mod: 'fm' }, + amateur2m: { start: 144, end: 148, step: 12.5, mod: 'fm' }, + pager: { start: 152, end: 160, step: 25, mod: 'fm' }, + amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' } +}; + +const audioPresets = { + fm: { freq: 98.1, mod: 'wfm' }, + airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency + marine: { freq: 156.8, mod: 'fm' }, // Channel 16 - distress + amateur2m: { freq: 146.52, mod: 'fm' }, // 2m calling frequency + amateur70cm: { freq: 446.0, mod: 'fm' } +}; + +// ============== SCANNER TOOLS CHECK ============== + +function checkScannerTools() { + fetch('/listening/tools') + .then(r => r.json()) + .then(data => { + const warnings = []; + if (!data.rtl_fm) { + warnings.push('rtl_fm not found - install rtl-sdr tools'); + } + if (!data.ffmpeg) { + warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)'); + } + + const warningDiv = document.getElementById('scannerToolsWarning'); + const warningText = document.getElementById('scannerToolsWarningText'); + if (warningDiv && warnings.length > 0) { + warningText.innerHTML = warnings.join('
'); + warningDiv.style.display = 'block'; + document.getElementById('scannerStartBtn').disabled = true; + document.getElementById('scannerStartBtn').style.opacity = '0.5'; + } else if (warningDiv) { + warningDiv.style.display = 'none'; + document.getElementById('scannerStartBtn').disabled = false; + document.getElementById('scannerStartBtn').style.opacity = '1'; + } + }) + .catch(() => {}); +} + +// ============== SCANNER HELPERS ============== + +/** + * Get the currently selected device from the global SDR selector + */ +function getSelectedDevice() { + const select = document.getElementById('deviceSelect'); + return parseInt(select?.value || '0'); +} + +/** + * Get the currently selected SDR type from the global selector + */ +function getSelectedSDRTypeForScanner() { + const select = document.getElementById('sdrTypeSelect'); + return select?.value || 'rtlsdr'; +} + +// ============== SCANNER PRESETS ============== + +function applyScannerPreset() { + const preset = document.getElementById('scannerPreset').value; + if (preset !== 'custom' && scannerPresets[preset]) { + const p = scannerPresets[preset]; + document.getElementById('scannerStartFreq').value = p.start; + document.getElementById('scannerEndFreq').value = p.end; + document.getElementById('scannerStep').value = p.step; + document.getElementById('scannerModulation').value = p.mod; + } +} + +// ============== SCANNER CONTROLS ============== + +function toggleScanner() { + if (isScannerRunning) { + stopScanner(); + } else { + startScanner(); + } +} + +function startScanner() { + // Use unified radio controls - read all current UI values + const startFreq = parseFloat(document.getElementById('radioScanStart')?.value || 118); + const endFreq = parseFloat(document.getElementById('radioScanEnd')?.value || 137); + const stepSelect = document.getElementById('radioScanStep'); + const step = stepSelect ? parseFloat(stepSelect.value) : 25; + const modulation = currentModulation || 'am'; + const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; + const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; + const dwellSelect = document.getElementById('radioScanDwell'); + const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10; + const device = getSelectedDevice(); + + // Check if using agent mode + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + listeningPostCurrentAgent = isAgentMode ? currentAgent : null; + + // Disable listen button for agent mode (audio can't stream over HTTP) + updateListenButtonState(isAgentMode); + + if (startFreq >= endFreq) { + if (typeof showNotification === 'function') { + showNotification('Scanner Error', 'End frequency must be greater than start'); + } + return; + } + + // Check if device is available (only for local mode) + if (!isAgentMode && typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) { + return; + } + + // Store scanner range for progress calculation + scannerStartFreq = startFreq; + scannerEndFreq = endFreq; + scannerFreqsScanned = 0; + scannerCycles = 0; + + // Update sidebar display + updateScannerDisplay('STARTING...', 'var(--accent-orange)'); + + // Show progress bars + const progressEl = document.getElementById('scannerProgress'); + if (progressEl) { + progressEl.style.display = 'block'; + document.getElementById('scannerRangeStart').textContent = startFreq.toFixed(1); + document.getElementById('scannerRangeEnd').textContent = endFreq.toFixed(1); + } + + const mainProgress = document.getElementById('mainScannerProgress'); + if (mainProgress) { + mainProgress.style.display = 'block'; + document.getElementById('mainRangeStart').textContent = startFreq.toFixed(1) + ' MHz'; + document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz'; + } + + // Determine endpoint based on agent mode + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/listening_post/start` + : '/listening/scanner/start'; + + fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + start_freq: startFreq, + end_freq: endFreq, + step: step, + modulation: modulation, + squelch: squelch, + gain: gain, + dwell_time: dwell, + device: device, + bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false + }) + }) + .then(r => r.json()) + .then(data => { + // Handle controller proxy response format + const scanResult = isAgentMode && data.result ? data.result : data; + + if (scanResult.status === 'started' || scanResult.status === 'success') { + if (!isAgentMode && typeof reserveDevice === 'function') reserveDevice(device, 'scanner'); + isScannerRunning = true; + isScannerPaused = false; + scannerSignalActive = false; + + // Update controls (with null checks) + const startBtn = document.getElementById('scannerStartBtn'); + if (startBtn) { + startBtn.textContent = 'Stop Scanner'; + startBtn.classList.add('active'); + } + const pauseBtn = document.getElementById('scannerPauseBtn'); + if (pauseBtn) pauseBtn.disabled = false; + + // Update radio scan button to show STOP + const radioScanBtn = document.getElementById('radioScanBtn'); + if (radioScanBtn) { + radioScanBtn.innerHTML = Icons.stop('icon--sm') + ' STOP'; + radioScanBtn.style.background = 'var(--accent-red)'; + radioScanBtn.style.borderColor = 'var(--accent-red)'; + } + + updateScannerDisplay('SCANNING', 'var(--accent-cyan)'); + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'Scanning...'; + + // Show level meter + const levelMeter = document.getElementById('scannerLevelMeter'); + if (levelMeter) levelMeter.style.display = 'block'; + + connectScannerStream(isAgentMode); + addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`); + if (typeof showNotification === 'function') { + showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`); + } + } else { + updateScannerDisplay('ERROR', 'var(--accent-red)'); + if (typeof showNotification === 'function') { + showNotification('Scanner Error', scanResult.message || scanResult.error || 'Failed to start'); + } + } + }) + .catch(err => { + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'ERROR'; + updateScannerDisplay('ERROR', 'var(--accent-red)'); + if (typeof showNotification === 'function') { + showNotification('Scanner Error', err.message); + } + }); +} + +function stopScanner() { + const isAgentMode = listeningPostCurrentAgent !== null; + const endpoint = isAgentMode + ? `/controller/agents/${listeningPostCurrentAgent}/listening_post/stop` + : '/listening/scanner/stop'; + + fetch(endpoint, { method: 'POST' }) + .then(() => { + if (!isAgentMode && typeof releaseDevice === 'function') releaseDevice('scanner'); + listeningPostCurrentAgent = null; + isScannerRunning = false; + isScannerPaused = false; + scannerSignalActive = false; + currentSignalLevel = 0; + + // Re-enable listen button (will be in local mode after stop) + updateListenButtonState(false); + + // Clear polling timer + if (listeningPostPollTimer) { + clearInterval(listeningPostPollTimer); + listeningPostPollTimer = null; + } + + // Update sidebar (with null checks) + const startBtn = document.getElementById('scannerStartBtn'); + if (startBtn) { + startBtn.textContent = 'Start Scanner'; + startBtn.classList.remove('active'); + } + const pauseBtn = document.getElementById('scannerPauseBtn'); + if (pauseBtn) { + pauseBtn.disabled = true; + pauseBtn.innerHTML = Icons.pause('icon--sm') + ' Pause'; + } + + // Update radio scan button + const radioScanBtn = document.getElementById('radioScanBtn'); + if (radioScanBtn) { + radioScanBtn.innerHTML = '📡 SCAN'; + radioScanBtn.style.background = ''; + radioScanBtn.style.borderColor = ''; + } + + updateScannerDisplay('STOPPED', 'var(--text-muted)'); + const currentFreq = document.getElementById('scannerCurrentFreq'); + if (currentFreq) currentFreq.textContent = '---.--- MHz'; + const modLabel = document.getElementById('scannerModLabel'); + if (modLabel) modLabel.textContent = '--'; + + const progressEl = document.getElementById('scannerProgress'); + if (progressEl) progressEl.style.display = 'none'; + + const signalPanel = document.getElementById('scannerSignalPanel'); + if (signalPanel) signalPanel.style.display = 'none'; + + const levelMeter = document.getElementById('scannerLevelMeter'); + if (levelMeter) levelMeter.style.display = 'none'; + + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'Ready'; + + // Update main display + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) { + mainModeLabel.textContent = 'SCANNER STOPPED'; + document.getElementById('mainScannerFreq').textContent = '---.---'; + document.getElementById('mainScannerFreq').style.color = 'var(--text-muted)'; + document.getElementById('mainScannerMod').textContent = '--'; + } + + const mainAnim = document.getElementById('mainScannerAnimation'); + if (mainAnim) mainAnim.style.display = 'none'; + + const mainProgress = document.getElementById('mainScannerProgress'); + if (mainProgress) mainProgress.style.display = 'none'; + + const mainSignalAlert = document.getElementById('mainSignalAlert'); + if (mainSignalAlert) mainSignalAlert.style.display = 'none'; + + // Stop scanner audio + const scannerAudio = document.getElementById('scannerAudioPlayer'); + if (scannerAudio) { + scannerAudio.pause(); + scannerAudio.src = ''; + } + + if (scannerEventSource) { + scannerEventSource.close(); + scannerEventSource = null; + } + addScannerLogEntry('Scanner stopped', ''); + }) + .catch(() => {}); +} + +function pauseScanner() { + const endpoint = isScannerPaused ? '/listening/scanner/resume' : '/listening/scanner/pause'; + fetch(endpoint, { method: 'POST' }) + .then(r => r.json()) + .then(data => { + isScannerPaused = !isScannerPaused; + const pauseBtn = document.getElementById('scannerPauseBtn'); + if (pauseBtn) pauseBtn.innerHTML = isScannerPaused ? Icons.play('icon--sm') + ' Resume' : Icons.pause('icon--sm') + ' Pause'; + const statusText = document.getElementById('scannerStatusText'); + if (statusText) { + statusText.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; + statusText.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; + } + + const activityStatus = document.getElementById('scannerActivityStatus'); + if (activityStatus) { + activityStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; + activityStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; + } + + // Update main display + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) { + mainModeLabel.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; + } + + addScannerLogEntry(isScannerPaused ? 'Scanner paused' : 'Scanner resumed', ''); + }) + .catch(() => {}); +} + +function skipSignal() { + if (!isScannerRunning) { + if (typeof showNotification === 'function') { + showNotification('Scanner', 'Scanner is not running'); + } + return; + } + + fetch('/listening/scanner/skip', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.status === 'skipped' && typeof showNotification === 'function') { + showNotification('Signal Skipped', `Continuing scan from ${data.frequency.toFixed(3)} MHz`); + } + }) + .catch(err => { + if (typeof showNotification === 'function') { + showNotification('Skip Error', err.message); + } + }); +} + +// ============== SCANNER STREAM ============== + +function connectScannerStream(isAgentMode = false) { + if (scannerEventSource) { + scannerEventSource.close(); + } + + // Use different stream endpoint for agent mode + const streamUrl = isAgentMode ? '/controller/stream/all' : '/listening/scanner/stream'; + scannerEventSource = new EventSource(streamUrl); + + scannerEventSource.onmessage = function(e) { + try { + const data = JSON.parse(e.data); + + if (isAgentMode) { + // Handle multi-agent stream format + if (data.scan_type === 'listening_post' && data.payload) { + const payload = data.payload; + payload.agent_name = data.agent_name; + handleScannerEvent(payload); + } + } else { + handleScannerEvent(data); + } + } catch (err) { + console.warn('Scanner parse error:', err); + } + }; + + scannerEventSource.onerror = function() { + if (isScannerRunning) { + setTimeout(() => connectScannerStream(isAgentMode), 2000); + } + }; + + // Start polling fallback for agent mode + if (isAgentMode) { + startListeningPostPolling(); + } +} + +// Track last activity count for polling +let lastListeningPostActivityCount = 0; + +function startListeningPostPolling() { + if (listeningPostPollTimer) return; + lastListeningPostActivityCount = 0; + + // Disable listen button for agent mode (audio can't stream over HTTP) + updateListenButtonState(true); + + const pollInterval = 2000; + listeningPostPollTimer = setInterval(async () => { + if (!isScannerRunning || !listeningPostCurrentAgent) { + clearInterval(listeningPostPollTimer); + listeningPostPollTimer = null; + return; + } + + try { + const response = await fetch(`/controller/agents/${listeningPostCurrentAgent}/listening_post/data`); + if (!response.ok) return; + + const data = await response.json(); + const result = data.result || data; + // Controller returns nested structure: data.data.data for agent mode data + const outerData = result.data || {}; + const modeData = outerData.data || outerData; + + // Process activity from polling response + const activity = modeData.activity || []; + if (activity.length > lastListeningPostActivityCount) { + const newActivity = activity.slice(lastListeningPostActivityCount); + newActivity.forEach(item => { + // Convert to scanner event format + const event = { + type: 'signal_found', + frequency: item.frequency, + level: item.level || item.signal_level, + modulation: item.modulation, + agent_name: result.agent_name || 'Remote Agent' + }; + handleScannerEvent(event); + }); + lastListeningPostActivityCount = activity.length; + } + + // Update current frequency if available + if (modeData.current_freq) { + handleScannerEvent({ + type: 'freq_change', + frequency: modeData.current_freq + }); + } + + // Update freqs scanned counter from agent data + if (modeData.freqs_scanned !== undefined) { + const freqsEl = document.getElementById('mainFreqsScanned'); + if (freqsEl) freqsEl.textContent = modeData.freqs_scanned; + scannerFreqsScanned = modeData.freqs_scanned; + } + + // Update signal count from agent data + if (modeData.signal_count !== undefined) { + const signalEl = document.getElementById('mainSignalCount'); + if (signalEl) signalEl.textContent = modeData.signal_count; + } + } catch (err) { + console.error('Listening Post polling error:', err); + } + }, pollInterval); +} + +function handleScannerEvent(data) { + switch (data.type) { + case 'freq_change': + case 'scan_update': + handleFrequencyUpdate(data); + break; + case 'signal_found': + handleSignalFound(data); + break; + case 'signal_lost': + case 'signal_skipped': + handleSignalLost(data); + break; + case 'log': + if (data.entry && data.entry.type === 'scan_cycle') { + scannerCycles++; + const cyclesEl = document.getElementById('mainScanCycles'); + if (cyclesEl) cyclesEl.textContent = scannerCycles; + } + break; + case 'stopped': + stopScanner(); + break; + } +} + +function handleFrequencyUpdate(data) { + const freqStr = data.frequency.toFixed(3); + + const currentFreq = document.getElementById('scannerCurrentFreq'); + if (currentFreq) currentFreq.textContent = freqStr + ' MHz'; + + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.textContent = freqStr; + + // Update progress bar + const progress = ((data.frequency - scannerStartFreq) / (scannerEndFreq - scannerStartFreq)) * 100; + const progressBar = document.getElementById('scannerProgressBar'); + if (progressBar) progressBar.style.width = Math.max(0, Math.min(100, progress)) + '%'; + + const mainProgressBar = document.getElementById('mainProgressBar'); + if (mainProgressBar) mainProgressBar.style.width = Math.max(0, Math.min(100, progress)) + '%'; + + scannerFreqsScanned++; + const freqsEl = document.getElementById('mainFreqsScanned'); + if (freqsEl) freqsEl.textContent = scannerFreqsScanned; + + // Update level meter if present + if (data.level !== undefined) { + // Store for synthesizer visualization + currentSignalLevel = data.level; + if (data.threshold !== undefined) { + signalLevelThreshold = data.threshold; + } + + const levelPercent = Math.min(100, (data.level / 5000) * 100); + const levelBar = document.getElementById('scannerLevelBar'); + if (levelBar) { + levelBar.style.width = levelPercent + '%'; + if (data.detected) { + levelBar.style.background = 'var(--accent-green)'; + } else if (data.level > (data.threshold || 0) * 0.7) { + levelBar.style.background = 'var(--accent-orange)'; + } else { + levelBar.style.background = 'var(--accent-cyan)'; + } + } + const levelValue = document.getElementById('scannerLevelValue'); + if (levelValue) levelValue.textContent = data.level; + } + + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = `${freqStr} MHz${data.level !== undefined ? ` (level: ${data.level})` : ''}`; +} + +function handleSignalFound(data) { + scannerSignalCount++; + scannerSignalActive = true; + const freqStr = data.frequency.toFixed(3); + + const signalCount = document.getElementById('scannerSignalCount'); + if (signalCount) signalCount.textContent = scannerSignalCount; + const mainSignalCount = document.getElementById('mainSignalCount'); + if (mainSignalCount) mainSignalCount.textContent = scannerSignalCount; + + // Update sidebar + updateScannerDisplay('SIGNAL FOUND', 'var(--accent-green)'); + const signalPanel = document.getElementById('scannerSignalPanel'); + if (signalPanel) signalPanel.style.display = 'block'; + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'Listening to signal...'; + + // Update main display + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) mainModeLabel.textContent = 'SIGNAL DETECTED'; + + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.style.color = 'var(--accent-green)'; + + const mainAnim = document.getElementById('mainScannerAnimation'); + if (mainAnim) mainAnim.style.display = 'none'; + + const mainSignalAlert = document.getElementById('mainSignalAlert'); + if (mainSignalAlert) mainSignalAlert.style.display = 'block'; + + // Start audio playback for the detected signal + if (data.audio_streaming) { + const scannerAudio = document.getElementById('scannerAudioPlayer'); + if (scannerAudio) { + // Pass the signal frequency and modulation to getStreamUrl + const streamUrl = getStreamUrl(data.frequency, data.modulation); + console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz'); + scannerAudio.src = streamUrl; + // Apply current volume from knob + const volumeKnob = document.getElementById('radioVolumeKnob'); + if (volumeKnob && volumeKnob._knob) { + scannerAudio.volume = volumeKnob._knob.getValue() / 100; + } else if (volumeKnob) { + const knobValue = parseFloat(volumeKnob.dataset.value) || 80; + scannerAudio.volume = knobValue / 100; + } + scannerAudio.play().catch(e => console.warn('[SCANNER] Audio autoplay blocked:', e)); + // Initialize audio visualizer to feed signal levels to synthesizer + initAudioVisualizer(); + } + } + + // Add to sidebar recent signals + if (typeof addSidebarRecentSignal === 'function') { + addSidebarRecentSignal(data.frequency, data.modulation); + } + + addScannerLogEntry('SIGNAL FOUND', `${freqStr} MHz (${data.modulation.toUpperCase()})`, 'signal'); + addSignalHit(data); + + if (typeof showNotification === 'function') { + showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`); + } +} + +function handleSignalLost(data) { + scannerSignalActive = false; + + // Update sidebar + updateScannerDisplay('SCANNING', 'var(--accent-cyan)'); + const signalPanel = document.getElementById('scannerSignalPanel'); + if (signalPanel) signalPanel.style.display = 'none'; + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'Scanning...'; + + // Update main display + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) mainModeLabel.textContent = 'SCANNING'; + + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.style.color = 'var(--accent-cyan)'; + + const mainAnim = document.getElementById('mainScannerAnimation'); + if (mainAnim) mainAnim.style.display = 'block'; + + const mainSignalAlert = document.getElementById('mainSignalAlert'); + if (mainSignalAlert) mainSignalAlert.style.display = 'none'; + + // Stop audio + const scannerAudio = document.getElementById('scannerAudioPlayer'); + if (scannerAudio) { + scannerAudio.pause(); + scannerAudio.src = ''; + } + + const logType = data.type === 'signal_skipped' ? 'info' : 'info'; + const logTitle = data.type === 'signal_skipped' ? 'Signal skipped' : 'Signal lost'; + addScannerLogEntry(logTitle, `${data.frequency.toFixed(3)} MHz`, logType); +} + +/** + * Update listen button state based on agent mode + * Audio streaming isn't practical over HTTP so disable for remote agents + */ +function updateListenButtonState(isAgentMode) { + const listenBtn = document.getElementById('radioListenBtn'); + if (!listenBtn) return; + + if (isAgentMode) { + listenBtn.disabled = true; + listenBtn.style.opacity = '0.5'; + listenBtn.style.cursor = 'not-allowed'; + listenBtn.title = 'Audio listening not available for remote agents'; + } else { + listenBtn.disabled = false; + listenBtn.style.opacity = '1'; + listenBtn.style.cursor = 'pointer'; + listenBtn.title = 'Listen to current frequency'; + } +} + +function updateScannerDisplay(mode, color) { + const modeLabel = document.getElementById('scannerModeLabel'); + if (modeLabel) { + modeLabel.textContent = mode; + modeLabel.style.color = color; + } + + const currentFreq = document.getElementById('scannerCurrentFreq'); + if (currentFreq) currentFreq.style.color = color; + + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) mainModeLabel.textContent = mode; + + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.style.color = color; +} + +// ============== SCANNER LOG ============== + +function addScannerLogEntry(title, detail, type = 'info') { + const now = new Date(); + const timestamp = now.toLocaleTimeString(); + const entry = { timestamp, title, detail, type }; + scannerLogEntries.unshift(entry); + + if (scannerLogEntries.length > 100) { + scannerLogEntries.pop(); + } + + // Color based on type + const getTypeColor = (t) => { + switch(t) { + case 'signal': return 'var(--accent-green)'; + case 'error': return 'var(--accent-red)'; + default: return 'var(--text-secondary)'; + } + }; + + // Update sidebar log + const sidebarLog = document.getElementById('scannerLog'); + if (sidebarLog) { + sidebarLog.innerHTML = scannerLogEntries.slice(0, 20).map(e => + `
+ [${e.timestamp}] + ${e.title} ${e.detail} +
` + ).join(''); + } + + // Update main activity log + const activityLog = document.getElementById('scannerActivityLog'); + if (activityLog) { + const getBorderColor = (t) => { + switch(t) { + case 'signal': return 'var(--accent-green)'; + case 'error': return 'var(--accent-red)'; + default: return 'var(--border-color)'; + } + }; + activityLog.innerHTML = scannerLogEntries.slice(0, 50).map(e => + `
+ [${e.timestamp}] + ${e.title} + ${e.detail} +
` + ).join(''); + } +} + +function addSignalHit(data) { + const tbody = document.getElementById('scannerHitsBody'); + if (!tbody) return; + + const now = Date.now(); + const freqKey = data.frequency.toFixed(3); + + // Check for duplicate + if (recentSignalHits.has(freqKey)) { + const lastHit = recentSignalHits.get(freqKey); + if (now - lastHit < 5000) return; + } + recentSignalHits.set(freqKey, now); + + // Clean up old entries + for (const [freq, time] of recentSignalHits) { + if (now - time > 30000) { + recentSignalHits.delete(freq); + } + } + + const timestamp = new Date().toLocaleTimeString(); + + if (tbody.innerHTML.includes('No signals detected')) { + tbody.innerHTML = ''; + } + + const mod = data.modulation || 'fm'; + const row = document.createElement('tr'); + row.style.borderBottom = '1px solid var(--border-color)'; + row.innerHTML = ` + ${timestamp} + ${data.frequency.toFixed(3)} + ${mod.toUpperCase()} + + + + `; + tbody.insertBefore(row, tbody.firstChild); + + while (tbody.children.length > 50) { + tbody.removeChild(tbody.lastChild); + } + + const hitCount = document.getElementById('scannerHitCount'); + if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`; + + // Feed to activity timeline if available + if (typeof addTimelineEvent === 'function') { + const normalized = typeof RFTimelineAdapter !== 'undefined' + ? RFTimelineAdapter.normalizeSignal({ + frequency: data.frequency, + rssi: data.rssi || data.signal_strength, + duration: data.duration || 2000, + modulation: data.modulation + }) + : { + id: String(data.frequency), + label: `${data.frequency.toFixed(3)} MHz`, + strength: 3, + duration: 2000, + type: 'rf' + }; + addTimelineEvent('listening', normalized); + } +} + +function clearScannerLog() { + scannerLogEntries = []; + scannerSignalCount = 0; + scannerFreqsScanned = 0; + scannerCycles = 0; + recentSignalHits.clear(); + + // Clear the timeline if available + const timeline = typeof getTimeline === 'function' ? getTimeline('listening') : null; + if (timeline) { + timeline.clear(); + } + + const signalCount = document.getElementById('scannerSignalCount'); + if (signalCount) signalCount.textContent = '0'; + + const mainSignalCount = document.getElementById('mainSignalCount'); + if (mainSignalCount) mainSignalCount.textContent = '0'; + + const mainFreqsScanned = document.getElementById('mainFreqsScanned'); + if (mainFreqsScanned) mainFreqsScanned.textContent = '0'; + + const mainScanCycles = document.getElementById('mainScanCycles'); + if (mainScanCycles) mainScanCycles.textContent = '0'; + + const sidebarLog = document.getElementById('scannerLog'); + if (sidebarLog) sidebarLog.innerHTML = '
Scanner activity will appear here...
'; + + const activityLog = document.getElementById('scannerActivityLog'); + if (activityLog) activityLog.innerHTML = '
Waiting for scanner to start...
'; + + const hitsBody = document.getElementById('scannerHitsBody'); + if (hitsBody) hitsBody.innerHTML = 'No signals detected'; + + const hitCount = document.getElementById('scannerHitCount'); + if (hitCount) hitCount.textContent = '0 signals found'; +} + +function exportScannerLog() { + if (scannerLogEntries.length === 0) { + if (typeof showNotification === 'function') { + showNotification('Export', 'No log entries to export'); + } + return; + } + + const csv = 'Timestamp,Event,Details\n' + scannerLogEntries.map(e => + `"${e.timestamp}","${e.title}","${e.detail}"` + ).join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `scanner_log_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + + if (typeof showNotification === 'function') { + showNotification('Export', 'Log exported to CSV'); + } +} + +// ============== AUDIO TOOLS CHECK ============== + +function checkAudioTools() { + fetch('/listening/tools') + .then(r => r.json()) + .then(data => { + audioToolsAvailable.rtl_fm = data.rtl_fm; + audioToolsAvailable.ffmpeg = data.ffmpeg; + + // Only rtl_fm/rx_fm + ffmpeg are required for direct streaming + const warnings = []; + if (!data.rtl_fm && !data.rx_fm) { + warnings.push('rtl_fm/rx_fm not found - install rtl-sdr or soapysdr-tools'); + } + if (!data.ffmpeg) { + warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)'); + } + + const warningDiv = document.getElementById('audioToolsWarning'); + const warningText = document.getElementById('audioToolsWarningText'); + if (warningDiv) { + if (warnings.length > 0) { + warningText.innerHTML = warnings.join('
'); + warningDiv.style.display = 'block'; + document.getElementById('audioStartBtn').disabled = true; + document.getElementById('audioStartBtn').style.opacity = '0.5'; + } else { + warningDiv.style.display = 'none'; + document.getElementById('audioStartBtn').disabled = false; + document.getElementById('audioStartBtn').style.opacity = '1'; + } + } + }) + .catch(() => {}); +} + +// ============== AUDIO PRESETS ============== + +function applyAudioPreset() { + const preset = document.getElementById('audioPreset').value; + const freqInput = document.getElementById('audioFrequency'); + const modSelect = document.getElementById('audioModulation'); + + if (audioPresets[preset]) { + freqInput.value = audioPresets[preset].freq; + modSelect.value = audioPresets[preset].mod; + } +} + +// ============== AUDIO CONTROLS ============== + +function toggleAudio() { + if (isAudioPlaying) { + stopAudio(); + } else { + startAudio(); + } +} + +function startAudio() { + const frequency = parseFloat(document.getElementById('audioFrequency').value); + const modulation = document.getElementById('audioModulation').value; + const squelch = parseInt(document.getElementById('audioSquelch').value); + const gain = parseInt(document.getElementById('audioGain').value); + const device = getSelectedDevice(); + + if (isNaN(frequency) || frequency <= 0) { + if (typeof showNotification === 'function') { + showNotification('Audio Error', 'Invalid frequency'); + } + return; + } + + // Check if device is in use + if (typeof getDeviceInUseBy === 'function') { + const usedBy = getDeviceInUseBy(device); + if (usedBy && usedBy !== 'audio') { + if (typeof showNotification === 'function') { + showNotification('SDR In Use', `Device ${device} is being used by ${usedBy.toUpperCase()}.`); + } + return; + } + } + + document.getElementById('audioStatus').textContent = 'STARTING...'; + document.getElementById('audioStatus').style.color = 'var(--accent-orange)'; + + // Use direct streaming - no Icecast needed + if (typeof reserveDevice === 'function') reserveDevice(device, 'audio'); + isAudioPlaying = true; + + // Build direct stream URL with parameters + const streamUrl = `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`; + console.log('Connecting to direct stream:', streamUrl); + + // Start browser audio playback + const audioPlayer = document.getElementById('audioPlayer'); + audioPlayer.src = streamUrl; + audioPlayer.volume = document.getElementById('audioVolume').value / 100; + + initAudioVisualizer(); + + audioPlayer.onplaying = () => { + document.getElementById('audioStatus').textContent = 'STREAMING'; + document.getElementById('audioStatus').style.color = 'var(--accent-green)'; + }; + + audioPlayer.onerror = (e) => { + console.error('Audio player error:', e); + document.getElementById('audioStatus').textContent = 'ERROR'; + document.getElementById('audioStatus').style.color = 'var(--accent-red)'; + if (typeof showNotification === 'function') { + showNotification('Audio Error', 'Stream error - check SDR connection'); + } + }; + + audioPlayer.play().catch(e => { + console.warn('Audio autoplay blocked:', e); + if (typeof showNotification === 'function') { + showNotification('Audio Ready', 'Click Play button again if audio does not start'); + } + }); + + document.getElementById('audioStartBtn').innerHTML = Icons.stop('icon--sm') + ' Stop Audio'; + document.getElementById('audioStartBtn').classList.add('active'); + document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz (' + modulation.toUpperCase() + ')'; + document.getElementById('audioDeviceStatus').textContent = 'SDR ' + device; + + if (typeof showNotification === 'function') { + showNotification('Audio Started', `Streaming ${frequency} MHz to browser`); + } +} + +async function stopAudio() { + stopAudioVisualizer(); + + const audioPlayer = document.getElementById('audioPlayer'); + if (audioPlayer) { + audioPlayer.pause(); + audioPlayer.src = ''; + } + + try { + await fetch('/listening/audio/stop', { method: 'POST' }); + if (typeof releaseDevice === 'function') releaseDevice('audio'); + isAudioPlaying = false; + document.getElementById('audioStartBtn').innerHTML = Icons.play('icon--sm') + ' Play Audio'; + document.getElementById('audioStartBtn').classList.remove('active'); + document.getElementById('audioStatus').textContent = 'STOPPED'; + document.getElementById('audioStatus').style.color = 'var(--text-muted)'; + document.getElementById('audioDeviceStatus').textContent = '--'; + } catch (e) { + console.error('Error stopping audio:', e); + } +} + +function updateAudioVolume() { + const audioPlayer = document.getElementById('audioPlayer'); + if (audioPlayer) { + audioPlayer.volume = document.getElementById('audioVolume').value / 100; + } +} + +function audioFreqUp() { + const input = document.getElementById('audioFrequency'); + const mod = document.getElementById('audioModulation').value; + const step = (mod === 'wfm') ? 0.2 : 0.025; + input.value = (parseFloat(input.value) + step).toFixed(2); + if (isAudioPlaying) { + tuneAudioFrequency(parseFloat(input.value)); + } +} + +function audioFreqDown() { + const input = document.getElementById('audioFrequency'); + const mod = document.getElementById('audioModulation').value; + const step = (mod === 'wfm') ? 0.2 : 0.025; + input.value = (parseFloat(input.value) - step).toFixed(2); + if (isAudioPlaying) { + tuneAudioFrequency(parseFloat(input.value)); + } +} + +function tuneAudioFrequency(frequency) { + fetch('/listening/audio/tune', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frequency: frequency }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'tuned') { + document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz'; + } + }) + .catch(() => { + stopAudio(); + setTimeout(startAudio, 300); + }); +} + +async function tuneToFrequency(freq, mod) { + try { + // Stop scanner if running + if (isScannerRunning) { + stopScanner(); + await new Promise(resolve => setTimeout(resolve, 300)); + } + + // Update frequency input + const freqInput = document.getElementById('radioScanStart'); + if (freqInput) { + freqInput.value = freq.toFixed(1); + } + + // Update modulation if provided + if (mod) { + setModulation(mod); + } + + // Update tuning dial (silent to avoid duplicate events) + const mainTuningDial = document.getElementById('mainTuningDial'); + if (mainTuningDial && mainTuningDial._dial) { + mainTuningDial._dial.setValue(freq, true); + } + + // Update frequency display + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) { + mainFreq.textContent = freq.toFixed(3); + } + + // Start listening immediately + await startDirectListenImmediate(); + + if (typeof showNotification === 'function') { + showNotification('Tuned', `Now listening to ${freq.toFixed(3)} MHz (${(mod || currentModulation).toUpperCase()})`); + } + } catch (err) { + console.error('Error tuning to frequency:', err); + if (typeof showNotification === 'function') { + showNotification('Tune Error', 'Failed to tune to frequency: ' + err.message); + } + } +} + +// ============== AUDIO VISUALIZER ============== + +function initAudioVisualizer() { + const audioPlayer = document.getElementById('scannerAudioPlayer'); + if (!audioPlayer) { + console.warn('[VISUALIZER] No audio player found'); + return; + } + + console.log('[VISUALIZER] Initializing with audio player, src:', audioPlayer.src); + + if (!visualizerContext) { + visualizerContext = new (window.AudioContext || window.webkitAudioContext)(); + console.log('[VISUALIZER] Created audio context'); + } + + if (visualizerContext.state === 'suspended') { + console.log('[VISUALIZER] Resuming suspended audio context'); + visualizerContext.resume(); + } + + if (!visualizerSource) { + try { + visualizerSource = visualizerContext.createMediaElementSource(audioPlayer); + visualizerAnalyser = visualizerContext.createAnalyser(); + visualizerAnalyser.fftSize = 256; + visualizerAnalyser.smoothingTimeConstant = 0.7; + + visualizerSource.connect(visualizerAnalyser); + visualizerAnalyser.connect(visualizerContext.destination); + console.log('[VISUALIZER] Audio source and analyser connected'); + } catch (e) { + console.error('[VISUALIZER] Could not create audio source:', e); + // Try to continue anyway if analyser exists + if (!visualizerAnalyser) return; + } + } else { + console.log('[VISUALIZER] Reusing existing audio source'); + } + + const container = document.getElementById('audioVisualizerContainer'); + if (container) container.style.display = 'block'; + + // Start the visualization loop + if (!visualizerAnimationId) { + console.log('[VISUALIZER] Starting draw loop'); + drawAudioVisualizer(); + } else { + console.log('[VISUALIZER] Draw loop already running'); + } +} + +function drawAudioVisualizer() { + if (!visualizerAnalyser) { + console.warn('[VISUALIZER] No analyser available'); + return; + } + + const canvas = document.getElementById('audioSpectrumCanvas'); + const ctx = canvas ? canvas.getContext('2d') : null; + const bufferLength = visualizerAnalyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + function draw() { + visualizerAnimationId = requestAnimationFrame(draw); + + visualizerAnalyser.getByteFrequencyData(dataArray); + + let sum = 0; + for (let i = 0; i < bufferLength; i++) { + sum += dataArray[i]; + } + const average = sum / bufferLength; + const levelPercent = (average / 255) * 100; + + // Feed audio level to synthesizer visualization during direct listening + if (isDirectListening || isScannerRunning) { + // Scale 0-255 average to 0-3000 range (matching SSE scan_update levels) + currentSignalLevel = (average / 255) * 3000; + } + + if (levelPercent > peakLevel) { + peakLevel = levelPercent; + } else { + peakLevel *= peakDecay; + } + + const meterFill = document.getElementById('audioSignalMeter'); + const meterPeak = document.getElementById('audioSignalPeak'); + const meterValue = document.getElementById('audioSignalValue'); + + if (meterFill) meterFill.style.width = levelPercent + '%'; + if (meterPeak) meterPeak.style.left = Math.min(peakLevel, 100) + '%'; + + const db = average > 0 ? Math.round(20 * Math.log10(average / 255)) : -60; + if (meterValue) meterValue.textContent = db + ' dB'; + + // Only draw spectrum if canvas exists + if (ctx && canvas) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const barWidth = canvas.width / bufferLength * 2.5; + let x = 0; + + for (let i = 0; i < bufferLength; i++) { + const barHeight = (dataArray[i] / 255) * canvas.height; + const hue = 200 - (i / bufferLength) * 60; + const lightness = 40 + (dataArray[i] / 255) * 30; + ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`; + ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight); + x += barWidth; + } + + ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.font = '8px Terminus'; + ctx.fillText('0', 2, canvas.height - 2); + ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2); + ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2); + } + } + + draw(); +} + +function stopAudioVisualizer() { + if (visualizerAnimationId) { + cancelAnimationFrame(visualizerAnimationId); + visualizerAnimationId = null; + } + + const meterFill = document.getElementById('audioSignalMeter'); + const meterPeak = document.getElementById('audioSignalPeak'); + const meterValue = document.getElementById('audioSignalValue'); + + if (meterFill) meterFill.style.width = '0%'; + if (meterPeak) meterPeak.style.left = '0%'; + if (meterValue) meterValue.textContent = '-∞ dB'; + + peakLevel = 0; + + const container = document.getElementById('audioVisualizerContainer'); + if (container) container.style.display = 'none'; +} + +// ============== RADIO KNOB CONTROLS ============== + +/** + * Update scanner config on the backend (for live updates while scanning) + */ +function updateScannerConfig(config) { + if (!isScannerRunning) return; + fetch('/listening/scanner/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }).catch(() => {}); +} + +/** + * Initialize radio knob controls and wire them to scanner parameters + */ +function initRadioKnobControls() { + // Squelch knob + const squelchKnob = document.getElementById('radioSquelchKnob'); + if (squelchKnob) { + squelchKnob.addEventListener('knobchange', function(e) { + const value = Math.round(e.detail.value); + const valueDisplay = document.getElementById('radioSquelchValue'); + if (valueDisplay) valueDisplay.textContent = value; + // Sync with scanner + updateScannerConfig({ squelch: value }); + // Restart stream if direct listening (squelch requires restart) + if (isDirectListening) { + startDirectListen(); + } + }); + } + + // Gain knob + const gainKnob = document.getElementById('radioGainKnob'); + if (gainKnob) { + gainKnob.addEventListener('knobchange', function(e) { + const value = Math.round(e.detail.value); + const valueDisplay = document.getElementById('radioGainValue'); + if (valueDisplay) valueDisplay.textContent = value; + // Sync with scanner + updateScannerConfig({ gain: value }); + // Restart stream if direct listening (gain requires restart) + if (isDirectListening) { + startDirectListen(); + } + }); + } + + // Volume knob - controls scanner audio player volume + const volumeKnob = document.getElementById('radioVolumeKnob'); + if (volumeKnob) { + volumeKnob.addEventListener('knobchange', function(e) { + const audioPlayer = document.getElementById('scannerAudioPlayer'); + if (audioPlayer) { + audioPlayer.volume = e.detail.value / 100; + console.log('[VOLUME] Set to', Math.round(e.detail.value) + '%'); + } + // Update knob value display + const valueDisplay = document.getElementById('radioVolumeValue'); + if (valueDisplay) valueDisplay.textContent = Math.round(e.detail.value); + }); + } + + // Main Tuning dial - updates frequency display and inputs + const mainTuningDial = document.getElementById('mainTuningDial'); + if (mainTuningDial) { + mainTuningDial.addEventListener('knobchange', function(e) { + const freq = e.detail.value; + // Update main frequency display + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) { + mainFreq.textContent = freq.toFixed(3); + } + // Update radio scan start input + const startFreqInput = document.getElementById('radioScanStart'); + if (startFreqInput) { + startFreqInput.value = freq.toFixed(1); + } + // Update sidebar frequency input + const sidebarFreq = document.getElementById('audioFrequency'); + if (sidebarFreq) { + sidebarFreq.value = freq.toFixed(3); + } + // If currently listening, retune to new frequency + if (isDirectListening) { + startDirectListen(); + } + }); + } + + // Legacy tuning dial support + const tuningDial = document.getElementById('tuningDial'); + if (tuningDial) { + tuningDial.addEventListener('knobchange', function(e) { + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.textContent = e.detail.value.toFixed(3); + const startFreqInput = document.getElementById('radioScanStart'); + if (startFreqInput) startFreqInput.value = e.detail.value.toFixed(1); + // If currently listening, retune to new frequency + if (isDirectListening) { + startDirectListen(); + } + }); + } + + // Sync radio scan range inputs with sidebar + const radioScanStart = document.getElementById('radioScanStart'); + const radioScanEnd = document.getElementById('radioScanEnd'); + + if (radioScanStart) { + radioScanStart.addEventListener('change', function() { + const sidebarStart = document.getElementById('scanStartFreq'); + if (sidebarStart) sidebarStart.value = this.value; + // Restart stream if direct listening + if (isDirectListening) { + startDirectListen(); + } + }); + } + + if (radioScanEnd) { + radioScanEnd.addEventListener('change', function() { + const sidebarEnd = document.getElementById('scanEndFreq'); + if (sidebarEnd) sidebarEnd.value = this.value; + }); + } +} + +/** + * Set modulation mode (called from HTML onclick) + */ +function setModulation(mod) { + // Update sidebar select + const modSelect = document.getElementById('scanModulation'); + if (modSelect) modSelect.value = mod; + + // Update audio modulation select + const audioMod = document.getElementById('audioModulation'); + if (audioMod) audioMod.value = mod; + + // Update button states in radio panel + document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mod === mod); + }); + + // Update main display badge + const mainBadge = document.getElementById('mainScannerMod'); + if (mainBadge) mainBadge.textContent = mod.toUpperCase(); +} + +/** + * Set band preset (called from HTML onclick) + */ +function setBand(band) { + const preset = scannerPresets[band]; + if (!preset) return; + + // Update button states + document.querySelectorAll('#bandBtnBank .radio-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.band === band); + }); + + // Update sidebar frequency inputs + const sidebarStart = document.getElementById('scanStartFreq'); + const sidebarEnd = document.getElementById('scanEndFreq'); + if (sidebarStart) sidebarStart.value = preset.start; + if (sidebarEnd) sidebarEnd.value = preset.end; + + // Update radio panel frequency inputs + const radioStart = document.getElementById('radioScanStart'); + const radioEnd = document.getElementById('radioScanEnd'); + if (radioStart) radioStart.value = preset.start; + if (radioEnd) radioEnd.value = preset.end; + + // Update tuning dial range and value (silent to avoid triggering restart) + const tuningDial = document.getElementById('tuningDial'); + if (tuningDial && tuningDial._dial) { + tuningDial._dial.min = preset.start; + tuningDial._dial.max = preset.end; + tuningDial._dial.setValue(preset.start, true); + } + + // Update main frequency display + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.textContent = preset.start.toFixed(3); + + // Update modulation + setModulation(preset.mod); + + // Update main range display if scanning + const rangeStart = document.getElementById('mainRangeStart'); + const rangeEnd = document.getElementById('mainRangeEnd'); + if (rangeStart) rangeStart.textContent = preset.start; + if (rangeEnd) rangeEnd.textContent = preset.end; + + // Store for scanner use + scannerStartFreq = preset.start; + scannerEndFreq = preset.end; +} + +// ============== SYNTHESIZER VISUALIZATION ============== + +let synthAnimationId = null; +let synthCanvas = null; +let synthCtx = null; +let synthBars = []; +const SYNTH_BAR_COUNT = 32; + +function initSynthesizer() { + synthCanvas = document.getElementById('synthesizerCanvas'); + if (!synthCanvas) return; + + // Set canvas size + const rect = synthCanvas.parentElement.getBoundingClientRect(); + synthCanvas.width = rect.width - 20; + synthCanvas.height = 60; + + synthCtx = synthCanvas.getContext('2d'); + + // Initialize bar heights + for (let i = 0; i < SYNTH_BAR_COUNT; i++) { + synthBars[i] = { height: 0, targetHeight: 0, velocity: 0 }; + } + + drawSynthesizer(); +} + +// Debug: log signal level periodically +let lastSynthDebugLog = 0; + +function drawSynthesizer() { + if (!synthCtx || !synthCanvas) return; + + const width = synthCanvas.width; + const height = synthCanvas.height; + const barWidth = (width / SYNTH_BAR_COUNT) - 2; + + // Clear canvas + synthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + synthCtx.fillRect(0, 0, width, height); + + // Determine activity level based on actual signal level + let activityLevel = 0; + let signalIntensity = 0; + + // Debug logging every 2 seconds + const now = Date.now(); + if (now - lastSynthDebugLog > 2000) { + console.log('[SYNTH] State:', { + isScannerRunning, + isDirectListening, + scannerSignalActive, + currentSignalLevel, + visualizerAnalyser: !!visualizerAnalyser + }); + lastSynthDebugLog = now; + } + + if (isScannerRunning && !isScannerPaused) { + // Use actual signal level data (0-5000 range, normalize to 0-1) + signalIntensity = Math.min(1, currentSignalLevel / 3000); + // Base activity when scanning, boosted by actual signal strength + activityLevel = 0.15 + (signalIntensity * 0.85); + if (scannerSignalActive) { + activityLevel = Math.max(activityLevel, 0.7); + } + } else if (isDirectListening) { + // For direct listening, use signal level if available + signalIntensity = Math.min(1, currentSignalLevel / 3000); + activityLevel = 0.2 + (signalIntensity * 0.8); + } + + // Update bar targets + for (let i = 0; i < SYNTH_BAR_COUNT; i++) { + if (activityLevel > 0) { + // Create wave-like pattern modulated by actual signal strength + const time = Date.now() / 200; + // Multiple wave frequencies for more organic feel + const wave1 = Math.sin(time + (i * 0.3)) * 0.2; + const wave2 = Math.sin(time * 1.7 + (i * 0.5)) * 0.15; + // Less randomness when signal is weak, more when strong + const randomAmount = 0.1 + (signalIntensity * 0.3); + const random = (Math.random() - 0.5) * randomAmount; + // Center bars tend to be taller (frequency spectrum shape) + const centerBoost = 1 - Math.abs((i - SYNTH_BAR_COUNT / 2) / (SYNTH_BAR_COUNT / 2)) * 0.4; + // Combine all factors with signal-driven amplitude + const baseHeight = 0.15 + (signalIntensity * 0.5); + synthBars[i].targetHeight = (baseHeight + wave1 + wave2 + random) * activityLevel * centerBoost * height; + } else { + // Idle state - minimal activity + synthBars[i].targetHeight = (Math.sin((Date.now() / 500) + (i * 0.5)) * 0.1 + 0.1) * height * 0.3; + } + + // Smooth animation - faster response when signal changes + const springStrength = signalIntensity > 0.3 ? 0.15 : 0.1; + const diff = synthBars[i].targetHeight - synthBars[i].height; + synthBars[i].velocity += diff * springStrength; + synthBars[i].velocity *= 0.8; + synthBars[i].height += synthBars[i].velocity; + synthBars[i].height = Math.max(2, Math.min(height - 4, synthBars[i].height)); + } + + // Draw bars + for (let i = 0; i < SYNTH_BAR_COUNT; i++) { + const x = i * (barWidth + 2) + 1; + const barHeight = synthBars[i].height; + const y = (height - barHeight) / 2; + + // Color gradient based on height and state + let hue, saturation, lightness; + if (scannerSignalActive) { + hue = 120; // Green for signal + saturation = 80; + lightness = 40 + (barHeight / height) * 30; + } else if (isScannerRunning || isDirectListening) { + hue = 190 + (i / SYNTH_BAR_COUNT) * 30; // Cyan to blue + saturation = 80; + lightness = 35 + (barHeight / height) * 25; + } else { + hue = 200; + saturation = 50; + lightness = 25 + (barHeight / height) * 15; + } + + const gradient = synthCtx.createLinearGradient(x, y, x, y + barHeight); + gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`); + gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`); + gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`); + + synthCtx.fillStyle = gradient; + synthCtx.fillRect(x, y, barWidth, barHeight); + + // Add glow effect for active bars + if (barHeight > height * 0.5 && activityLevel > 0.5) { + synthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`; + synthCtx.shadowBlur = 8; + synthCtx.fillRect(x, y, barWidth, barHeight); + synthCtx.shadowBlur = 0; + } + } + + // Draw center line + synthCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)'; + synthCtx.lineWidth = 1; + synthCtx.beginPath(); + synthCtx.moveTo(0, height / 2); + synthCtx.lineTo(width, height / 2); + synthCtx.stroke(); + + // Debug: show signal level value + if (isScannerRunning || isDirectListening) { + synthCtx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + synthCtx.font = '9px monospace'; + synthCtx.fillText(`lvl:${Math.round(currentSignalLevel)}`, 4, 10); + } + + synthAnimationId = requestAnimationFrame(drawSynthesizer); +} + +function stopSynthesizer() { + if (synthAnimationId) { + cancelAnimationFrame(synthAnimationId); + synthAnimationId = null; + } +} + +// ============== INITIALIZATION ============== + +/** + * Get the audio stream URL with parameters + * Streams directly from Flask - no Icecast needed + */ +function getStreamUrl(freq, mod) { + const frequency = freq || parseFloat(document.getElementById('radioScanStart')?.value) || 118.0; + const modulation = mod || currentModulation || 'am'; + const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; + const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; + return `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`; +} + +function initListeningPost() { + checkScannerTools(); + checkAudioTools(); + + // WebSocket audio disabled for now - using HTTP streaming + // initWebSocketAudio(); + + // Initialize synthesizer visualization + initSynthesizer(); + + // Initialize radio knobs if the component is available + if (typeof initRadioKnobs === 'function') { + initRadioKnobs(); + } + + // Connect radio knobs to scanner controls + initRadioKnobControls(); + + // Step dropdown - sync with scanner when changed + const stepSelect = document.getElementById('radioScanStep'); + if (stepSelect) { + stepSelect.addEventListener('change', function() { + const step = parseFloat(this.value); + console.log('[SCANNER] Step changed to:', step, 'kHz'); + updateScannerConfig({ step: step }); + }); + } + + // Dwell dropdown - sync with scanner when changed + const dwellSelect = document.getElementById('radioScanDwell'); + if (dwellSelect) { + dwellSelect.addEventListener('change', function() { + const dwell = parseInt(this.value); + console.log('[SCANNER] Dwell changed to:', dwell, 's'); + updateScannerConfig({ dwell_time: dwell }); + }); + } + + // Set up audio player error handling + const audioPlayer = document.getElementById('audioPlayer'); + if (audioPlayer) { + audioPlayer.addEventListener('error', function(e) { + console.warn('Audio player error:', e); + if (isAudioPlaying && audioReconnectAttempts < MAX_AUDIO_RECONNECT) { + audioReconnectAttempts++; + setTimeout(() => { + audioPlayer.src = getStreamUrl(); + audioPlayer.play().catch(() => {}); + }, 500); + } + }); + + audioPlayer.addEventListener('stalled', function() { + if (isAudioPlaying) { + audioPlayer.load(); + audioPlayer.play().catch(() => {}); + } + }); + + audioPlayer.addEventListener('playing', function() { + audioReconnectAttempts = 0; + }); + } + + // Keyboard controls for frequency tuning + document.addEventListener('keydown', function(e) { + // Only active in listening mode + if (typeof currentMode !== 'undefined' && currentMode !== 'listening') { + return; + } + + // Don't intercept if user is typing in an input + const activeEl = document.activeElement; + if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT')) { + return; + } + + // Arrow keys for tuning + // Up/Down: fine tuning (Shift for ultra-fine) + // Left/Right: coarse tuning (Shift for very coarse) + let delta = 0; + switch (e.key) { + case 'ArrowUp': + delta = e.shiftKey ? 0.005 : 0.05; + break; + case 'ArrowDown': + delta = e.shiftKey ? -0.005 : -0.05; + break; + case 'ArrowRight': + delta = e.shiftKey ? 1 : 0.1; + break; + case 'ArrowLeft': + delta = e.shiftKey ? -1 : -0.1; + break; + default: + return; // Not a tuning key + } + + e.preventDefault(); + tuneFreq(delta); + }); + + // Check if we arrived from Spy Stations with a tune request + checkIncomingTuneRequest(); +} + +/** + * Check for incoming tune request from Spy Stations or other pages + */ +function checkIncomingTuneRequest() { + const tuneFreq = sessionStorage.getItem('tuneFrequency'); + const tuneMode = sessionStorage.getItem('tuneMode'); + + if (tuneFreq) { + // Clear the session storage first + sessionStorage.removeItem('tuneFrequency'); + sessionStorage.removeItem('tuneMode'); + + // Parse and validate frequency + const freq = parseFloat(tuneFreq); + if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) { + console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default'); + + // Determine modulation (default to USB for HF/number stations) + const mod = tuneMode || (freq < 30 ? 'usb' : 'am'); + + // Use quickTune to set frequency and modulation + quickTune(freq, mod); + + // Show notification + if (typeof showNotification === 'function') { + showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode'); + } + } + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', initListeningPost); + +// ============== UNIFIED RADIO CONTROLS ============== + +/** + * Toggle direct listen mode (tune to start frequency and listen) + */ +function toggleDirectListen() { + console.log('[LISTEN] toggleDirectListen called, isDirectListening:', isDirectListening); + if (isDirectListening) { + stopDirectListen(); + } else { + // First press - start immediately, don't debounce + startDirectListenImmediate(); + } +} + +// Debounce for startDirectListen +let listenDebounceTimer = null; +// Flag to prevent overlapping restart attempts +let isRestarting = false; +// Flag indicating another restart is needed after current one finishes +let restartPending = false; +// Debounce for frequency tuning (user might be scrolling through) +// Needs to be long enough for SDR to fully release between restarts +const TUNE_DEBOUNCE_MS = 600; + +/** + * Start direct listening - debounced for frequency changes + */ +function startDirectListen() { + if (listenDebounceTimer) { + clearTimeout(listenDebounceTimer); + } + listenDebounceTimer = setTimeout(async () => { + // If already restarting, mark that we need another restart when done + if (isRestarting) { + console.log('[LISTEN] Restart in progress, will retry after'); + restartPending = true; + return; + } + + await _startDirectListenInternal(); + + // If another restart was requested during this one, do it now + while (restartPending) { + restartPending = false; + console.log('[LISTEN] Processing pending restart'); + await _startDirectListenInternal(); + } + }, TUNE_DEBOUNCE_MS); +} + +/** + * Start listening immediately (no debounce) - for button press + */ +async function startDirectListenImmediate() { + if (listenDebounceTimer) { + clearTimeout(listenDebounceTimer); + listenDebounceTimer = null; + } + restartPending = false; // Clear any pending + if (isRestarting) { + console.log('[LISTEN] Waiting for current restart to finish...'); + // Wait for current restart to complete (max 5 seconds) + let waitCount = 0; + while (isRestarting && waitCount < 50) { + await new Promise(r => setTimeout(r, 100)); + waitCount++; + } + } + await _startDirectListenInternal(); +} + +// ============== WEBSOCKET AUDIO ============== + +/** + * Initialize WebSocket audio connection + */ +function initWebSocketAudio() { + if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) { + return audioWebSocket; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/audio`; + + console.log('[WS-AUDIO] Connecting to:', wsUrl); + audioWebSocket = new WebSocket(wsUrl); + audioWebSocket.binaryType = 'arraybuffer'; + + audioWebSocket.onopen = () => { + console.log('[WS-AUDIO] Connected'); + isWebSocketAudio = true; + }; + + audioWebSocket.onclose = () => { + console.log('[WS-AUDIO] Disconnected'); + isWebSocketAudio = false; + audioWebSocket = null; + }; + + audioWebSocket.onerror = (e) => { + console.error('[WS-AUDIO] Error:', e); + isWebSocketAudio = false; + }; + + audioWebSocket.onmessage = (event) => { + if (typeof event.data === 'string') { + // JSON message (status updates) + try { + const msg = JSON.parse(event.data); + console.log('[WS-AUDIO] Status:', msg); + if (msg.status === 'error') { + addScannerLogEntry('Audio error: ' + msg.message, '', 'error'); + } + } catch (e) {} + } else { + // Binary data (audio) + handleWebSocketAudioData(event.data); + } + }; + + return audioWebSocket; +} + +/** + * Handle incoming WebSocket audio data + */ +function handleWebSocketAudioData(data) { + const audioPlayer = document.getElementById('scannerAudioPlayer'); + if (!audioPlayer) return; + + // Use MediaSource API to stream audio + if (!audioPlayer.msSource) { + setupMediaSource(audioPlayer); + } + + if (audioPlayer.sourceBuffer && !audioPlayer.sourceBuffer.updating) { + try { + audioPlayer.sourceBuffer.appendBuffer(new Uint8Array(data)); + } catch (e) { + // Buffer full or other error, skip this chunk + } + } else { + // Queue data for later + audioQueue.push(new Uint8Array(data)); + if (audioQueue.length > 50) audioQueue.shift(); // Prevent memory buildup + } +} + +/** + * Setup MediaSource for streaming audio + */ +function setupMediaSource(audioPlayer) { + if (!window.MediaSource) { + console.warn('[WS-AUDIO] MediaSource not supported'); + return; + } + + const mediaSource = new MediaSource(); + audioPlayer.src = URL.createObjectURL(mediaSource); + audioPlayer.msSource = mediaSource; + + mediaSource.addEventListener('sourceopen', () => { + try { + const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg'); + audioPlayer.sourceBuffer = sourceBuffer; + + sourceBuffer.addEventListener('updateend', () => { + // Process queued data + if (audioQueue.length > 0 && !sourceBuffer.updating) { + try { + sourceBuffer.appendBuffer(audioQueue.shift()); + } catch (e) {} + } + }); + } catch (e) { + console.error('[WS-AUDIO] Failed to create source buffer:', e); + } + }); +} + +/** + * Send command over WebSocket + */ +function sendWebSocketCommand(cmd, config = {}) { + if (!audioWebSocket || audioWebSocket.readyState !== WebSocket.OPEN) { + initWebSocketAudio(); + // Wait for connection and retry + setTimeout(() => sendWebSocketCommand(cmd, config), 500); + return; + } + + audioWebSocket.send(JSON.stringify({ cmd, config })); +} + +async function _startDirectListenInternal() { + console.log('[LISTEN] _startDirectListenInternal called'); + + // Prevent overlapping restarts + if (isRestarting) { + console.log('[LISTEN] Already restarting, skipping'); + return; + } + isRestarting = true; + + try { + if (isScannerRunning) { + stopScanner(); + } + + const freqInput = document.getElementById('radioScanStart'); + const freq = freqInput ? parseFloat(freqInput.value) : 118.0; + const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; + const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; + + console.log('[LISTEN] Tuning to:', freq, 'MHz', currentModulation); + + const listenBtn = document.getElementById('radioListenBtn'); + if (listenBtn) { + listenBtn.innerHTML = Icons.loader('icon--sm') + ' TUNING...'; + listenBtn.style.background = 'var(--accent-orange)'; + listenBtn.style.borderColor = 'var(--accent-orange)'; + } + + const audioPlayer = document.getElementById('scannerAudioPlayer'); + if (!audioPlayer) { + addScannerLogEntry('Audio player not found', '', 'error'); + updateDirectListenUI(false); + return; + } + + // Fully reset audio element to clean state + audioPlayer.oncanplay = null; // Remove old handler + try { + audioPlayer.pause(); + } catch (e) {} + audioPlayer.removeAttribute('src'); + audioPlayer.load(); // Reset the element + + // Start audio on backend (it handles stopping old stream) + const response = await fetch('/listening/audio/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + frequency: freq, + modulation: currentModulation, + squelch: squelch, + gain: gain + }) + }); + + const result = await response.json(); + console.log('[LISTEN] Backend:', result.status); + + if (result.status !== 'started') { + console.error('[LISTEN] Failed:', result.message); + addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error'); + isDirectListening = false; + updateDirectListenUI(false); + return; + } + + // Wait for stream to be ready (backend needs time after restart) + await new Promise(r => setTimeout(r, 300)); + + // Connect to new stream + const streamUrl = `/listening/audio/stream?t=${Date.now()}`; + console.log('[LISTEN] Connecting to stream:', streamUrl); + audioPlayer.src = streamUrl; + + // Apply current volume from knob + const volumeKnob = document.getElementById('radioVolumeKnob'); + if (volumeKnob && volumeKnob._knob) { + audioPlayer.volume = volumeKnob._knob.getValue() / 100; + } else if (volumeKnob) { + const knobValue = parseFloat(volumeKnob.dataset.value) || 80; + audioPlayer.volume = knobValue / 100; + } + + // Wait for audio to be ready then play + audioPlayer.oncanplay = () => { + console.log('[LISTEN] Audio can play'); + audioPlayer.play().catch(e => console.warn('[LISTEN] Autoplay blocked:', e)); + }; + + // Also try to play immediately (some browsers need this) + audioPlayer.play().catch(e => { + console.log('[LISTEN] Initial play blocked, waiting for canplay'); + }); + + // Initialize audio visualizer to feed signal levels to synthesizer + initAudioVisualizer(); + + isDirectListening = true; + updateDirectListenUI(true, freq); + addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal'); + + } catch (e) { + console.error('[LISTEN] Error:', e); + addScannerLogEntry('Error: ' + e.message, '', 'error'); + isDirectListening = false; + updateDirectListenUI(false); + } finally { + isRestarting = false; + } +} + +/** + * Stop direct listening + */ +function stopDirectListen() { + console.log('[LISTEN] Stopping'); + + // Clear all pending state + if (listenDebounceTimer) { + clearTimeout(listenDebounceTimer); + listenDebounceTimer = null; + } + restartPending = false; + + const audioPlayer = document.getElementById('scannerAudioPlayer'); + if (audioPlayer) { + audioPlayer.pause(); + // Clear MediaSource if using WebSocket + if (audioPlayer.msSource) { + try { + audioPlayer.msSource.endOfStream(); + } catch (e) {} + audioPlayer.msSource = null; + audioPlayer.sourceBuffer = null; + } + audioPlayer.src = ''; + } + audioQueue = []; + + // Stop via WebSocket if connected + if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) { + sendWebSocketCommand('stop'); + } + + // Also stop via HTTP (fallback) + fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {}); + + isDirectListening = false; + currentSignalLevel = 0; + updateDirectListenUI(false); + addScannerLogEntry('Listening stopped'); +} + +/** + * Update UI for direct listen mode + */ +function updateDirectListenUI(isPlaying, freq) { + const listenBtn = document.getElementById('radioListenBtn'); + const statusLabel = document.getElementById('mainScannerModeLabel'); + const freqDisplay = document.getElementById('mainScannerFreq'); + const quickStatus = document.getElementById('lpQuickStatus'); + const quickFreq = document.getElementById('lpQuickFreq'); + + if (listenBtn) { + if (isPlaying) { + listenBtn.innerHTML = Icons.stop('icon--sm') + ' STOP'; + listenBtn.style.background = 'var(--accent-red)'; + listenBtn.style.borderColor = 'var(--accent-red)'; + } else { + listenBtn.innerHTML = Icons.headphones('icon--sm') + ' LISTEN'; + listenBtn.style.background = 'var(--accent-purple)'; + listenBtn.style.borderColor = 'var(--accent-purple)'; + } + } + + if (statusLabel) { + statusLabel.textContent = isPlaying ? 'LISTENING' : 'STOPPED'; + statusLabel.style.color = isPlaying ? 'var(--accent-green)' : 'var(--text-muted)'; + } + + if (freqDisplay && freq) { + freqDisplay.textContent = freq.toFixed(3); + } + + if (quickStatus) { + quickStatus.textContent = isPlaying ? 'LISTENING' : 'IDLE'; + quickStatus.style.color = isPlaying ? 'var(--accent-green)' : 'var(--accent-cyan)'; + } + + if (quickFreq && freq) { + quickFreq.textContent = freq.toFixed(3) + ' MHz'; + } +} + +/** + * Tune frequency by delta + */ +function tuneFreq(delta) { + const freqInput = document.getElementById('radioScanStart'); + if (freqInput) { + let newFreq = parseFloat(freqInput.value) + delta; + // Round to 3 decimal places to avoid floating-point precision issues + newFreq = Math.round(newFreq * 1000) / 1000; + newFreq = Math.max(24, Math.min(1800, newFreq)); + freqInput.value = newFreq.toFixed(3); + + // Update display + const freqDisplay = document.getElementById('mainScannerFreq'); + if (freqDisplay) { + freqDisplay.textContent = newFreq.toFixed(3); + } + + // Update tuning dial position (silent to avoid duplicate restart) + const mainTuningDial = document.getElementById('mainTuningDial'); + if (mainTuningDial && mainTuningDial._dial) { + mainTuningDial._dial.setValue(newFreq, true); + } + + const quickFreq = document.getElementById('lpQuickFreq'); + if (quickFreq) { + quickFreq.textContent = newFreq.toFixed(3) + ' MHz'; + } + + // If currently listening, restart stream at new frequency + if (isDirectListening) { + startDirectListen(); + } + } +} + +/** + * Quick tune to a preset frequency + */ +function quickTune(freq, mod) { + // Update frequency inputs + const startInput = document.getElementById('radioScanStart'); + if (startInput) { + startInput.value = freq; + } + + // Update modulation (don't trigger auto-restart here, we'll handle it below) + if (mod) { + currentModulation = mod; + // Update modulation UI without triggering restart + document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mod === mod); + }); + const badge = document.getElementById('mainScannerMod'); + if (badge) { + const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' }; + badge.textContent = modLabels[mod] || mod.toUpperCase(); + } + } + + // Update display + const freqDisplay = document.getElementById('mainScannerFreq'); + if (freqDisplay) { + freqDisplay.textContent = freq.toFixed(3); + } + + // Update tuning dial position (silent to avoid duplicate restart) + const mainTuningDial = document.getElementById('mainTuningDial'); + if (mainTuningDial && mainTuningDial._dial) { + mainTuningDial._dial.setValue(freq, true); + } + + const quickFreq = document.getElementById('lpQuickFreq'); + if (quickFreq) { + quickFreq.textContent = freq.toFixed(3) + ' MHz'; + } + + addScannerLogEntry(`Quick tuned to ${freq.toFixed(3)} MHz (${mod.toUpperCase()})`); + + // If currently listening, restart immediately (this is a deliberate preset selection) + if (isDirectListening) { + startDirectListenImmediate(); + } +} + +/** + * Enhanced setModulation to also update currentModulation + * Uses immediate restart if currently listening + */ +const originalSetModulation = window.setModulation; +window.setModulation = function(mod) { + console.log('[MODULATION] Setting modulation to:', mod, 'isListening:', isDirectListening); + currentModulation = mod; + + // Update modulation button states + document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mod === mod); + }); + + // Update badge + const badge = document.getElementById('mainScannerMod'); + if (badge) { + const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' }; + badge.textContent = modLabels[mod] || mod.toUpperCase(); + } + + // Update scanner modulation select if exists + const modSelect = document.getElementById('scannerModulation'); + if (modSelect) { + modSelect.value = mod; + } + + // Sync with scanner if running + updateScannerConfig({ modulation: mod }); + + // If currently listening, restart immediately (deliberate modulation change) + if (isDirectListening) { + console.log('[MODULATION] Restarting audio with new modulation:', mod); + startDirectListenImmediate(); + } else { + console.log('[MODULATION] Not listening, just updated UI'); + } +}; + +/** + * Update sidebar quick status + */ +function updateQuickStatus() { + const quickStatus = document.getElementById('lpQuickStatus'); + const quickFreq = document.getElementById('lpQuickFreq'); + const quickSignals = document.getElementById('lpQuickSignals'); + + if (quickStatus) { + if (isScannerRunning) { + quickStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; + quickStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; + } else if (isDirectListening) { + quickStatus.textContent = 'LISTENING'; + quickStatus.style.color = 'var(--accent-green)'; + } else { + quickStatus.textContent = 'IDLE'; + quickStatus.style.color = 'var(--accent-cyan)'; + } + } + + if (quickSignals) { + quickSignals.textContent = scannerSignalCount; + } +} + +// ============== SIDEBAR CONTROLS ============== + +// Frequency bookmarks stored in localStorage +let frequencyBookmarks = []; + +/** + * Load bookmarks from localStorage + */ +function loadFrequencyBookmarks() { + try { + const saved = localStorage.getItem('lpBookmarks'); + if (saved) { + frequencyBookmarks = JSON.parse(saved); + renderBookmarks(); + } + } catch (e) { + console.warn('Failed to load bookmarks:', e); + } +} + +/** + * Save bookmarks to localStorage + */ +function saveFrequencyBookmarks() { + try { + localStorage.setItem('lpBookmarks', JSON.stringify(frequencyBookmarks)); + } catch (e) { + console.warn('Failed to save bookmarks:', e); + } +} + +/** + * Add a frequency bookmark + */ +function addFrequencyBookmark() { + const input = document.getElementById('bookmarkFreqInput'); + if (!input) return; + + const freq = parseFloat(input.value); + if (isNaN(freq) || freq <= 0) { + if (typeof showNotification === 'function') { + showNotification('Invalid Frequency', 'Please enter a valid frequency'); + } + return; + } + + // Check for duplicates + if (frequencyBookmarks.some(b => Math.abs(b.freq - freq) < 0.001)) { + if (typeof showNotification === 'function') { + showNotification('Duplicate', 'This frequency is already bookmarked'); + } + return; + } + + frequencyBookmarks.push({ + freq: freq, + mod: currentModulation || 'am', + added: new Date().toISOString() + }); + + saveFrequencyBookmarks(); + renderBookmarks(); + input.value = ''; + + if (typeof showNotification === 'function') { + showNotification('Bookmark Added', `${freq.toFixed(3)} MHz saved`); + } +} + +/** + * Remove a bookmark by index + */ +function removeBookmark(index) { + frequencyBookmarks.splice(index, 1); + saveFrequencyBookmarks(); + renderBookmarks(); +} + +/** + * Render bookmarks list + */ +function renderBookmarks() { + const container = document.getElementById('bookmarksList'); + if (!container) return; + + if (frequencyBookmarks.length === 0) { + container.innerHTML = '
No bookmarks saved
'; + return; + } + + container.innerHTML = frequencyBookmarks.map((b, i) => ` +
+ ${b.freq.toFixed(3)} MHz + ${b.mod.toUpperCase()} + +
+ `).join(''); +} + + +/** + * Add a signal to the sidebar recent signals list + */ +function addSidebarRecentSignal(freq, mod) { + const container = document.getElementById('sidebarRecentSignals'); + if (!container) return; + + // Clear placeholder if present + if (container.innerHTML.includes('No signals yet')) { + container.innerHTML = ''; + } + + const timestamp = new Date().toLocaleTimeString(); + const signalDiv = document.createElement('div'); + signalDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 3px 6px; background: rgba(0,255,100,0.1); border-left: 2px solid var(--accent-green); margin-bottom: 2px; border-radius: 2px;'; + signalDiv.innerHTML = ` + ${freq.toFixed(3)} + ${timestamp} + `; + + container.insertBefore(signalDiv, container.firstChild); + + // Keep only last 10 signals + while (container.children.length > 10) { + container.removeChild(container.lastChild); + } +} + +// Load bookmarks on init +document.addEventListener('DOMContentLoaded', loadFrequencyBookmarks); + +/** + * Set listening post running state from external source (agent sync). + * Called by syncModeUI in agents.js when switching to an agent that already has scan running. + */ +function setListeningPostRunning(isRunning, agentId = null) { + console.log(`[ListeningPost] setListeningPostRunning: ${isRunning}, agent: ${agentId}`); + + isScannerRunning = isRunning; + + if (isRunning && agentId !== null && agentId !== 'local') { + // Agent has scan running - sync UI and start polling + listeningPostCurrentAgent = agentId; + + // Update main scan button (radioScanBtn is the actual ID) + const radioScanBtn = document.getElementById('radioScanBtn'); + if (radioScanBtn) { + radioScanBtn.innerHTML = 'STOP'; + radioScanBtn.style.background = 'var(--accent-red)'; + radioScanBtn.style.borderColor = 'var(--accent-red)'; + } + + // Update status display + updateScannerDisplay('SCANNING', 'var(--accent-green)'); + + // Disable listen button (can't stream audio from agent) + updateListenButtonState(true); + + // Start polling for agent data + startListeningPostPolling(); + } else if (!isRunning) { + // Not running - reset UI + listeningPostCurrentAgent = null; + + // Reset scan button + const radioScanBtn = document.getElementById('radioScanBtn'); + if (radioScanBtn) { + radioScanBtn.innerHTML = 'SCAN'; + radioScanBtn.style.background = ''; + radioScanBtn.style.borderColor = ''; + } + + // Update status + updateScannerDisplay('IDLE', 'var(--text-secondary)'); + + // Only re-enable listen button if we're in local mode + // (agent mode can't stream audio over HTTP) + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + updateListenButtonState(isAgentMode); + + // Clear polling + if (listeningPostPollTimer) { + clearInterval(listeningPostPollTimer); + listeningPostPollTimer = null; + } + } +} + +// Export for agent sync +window.setListeningPostRunning = setListeningPostRunning; +window.updateListenButtonState = updateListenButtonState; + +// Export functions for HTML onclick handlers +window.toggleDirectListen = toggleDirectListen; +window.startDirectListen = startDirectListen; +window.stopDirectListen = stopDirectListen; +window.toggleScanner = toggleScanner; +window.startScanner = startScanner; +window.stopScanner = stopScanner; +window.pauseScanner = pauseScanner; +window.skipSignal = skipSignal; +// Note: setModulation is already exported with enhancements above +window.setBand = setBand; +window.tuneFreq = tuneFreq; +window.quickTune = quickTune; +window.checkIncomingTuneRequest = checkIncomingTuneRequest; +window.addFrequencyBookmark = addFrequencyBookmark; +window.removeBookmark = removeBookmark; +window.tuneToFrequency = tuneToFrequency; +window.clearScannerLog = clearScannerLog; +window.exportScannerLog = exportScannerLog; + diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js index df598c5..945e9f8 100644 --- a/static/js/modes/spy-stations.js +++ b/static/js/modes/spy-stations.js @@ -84,7 +84,7 @@ const SpyStations = (function() { modeContainer.innerHTML = modes.map(m => ` `).join(''); } diff --git a/static/vendor/fonts/Terminus.ttf b/static/vendor/fonts/Terminus.ttf new file mode 100644 index 0000000..7a82c19 Binary files /dev/null and b/static/vendor/fonts/Terminus.ttf differ diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 6e06310..be182f8 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -1,4904 +1,4904 @@ - - - - - - AIRCRAFT RADAR // INTERCEPT - See the Invisible - - {% if offline_settings.fonts_source == 'local' %} - - {% else %} - - {% endif %} - - {% if offline_settings.assets_source == 'local' %} - - - {% else %} - - - {% endif %} + + + + + + AIRCRAFT RADAR // INTERCEPT - See the Invisible + + {% if offline_settings.fonts_source == 'local' %} + + {% else %} + + {% endif %} + + {% if offline_settings.assets_source == 'local' %} + + + {% else %} + + + {% endif %} - - - - -
-
- -
- -
- -
- - - -
- Back - Main Dashboard -
+ + + + +
+
+ +
+ +
+ +
+ + + +
+ Back + Main Dashboard +
{% set active_mode = 'adsb' %} {% include 'partials/nav.html' with context %} - - -
-
-
- 0 - NOW -
-
- 0 - SEEN -
-
- 0 - MAX NM -
-
- - - HIGH FL -
-
- - - FAST KT -
-
- - - NEAR NM -
-
- 0 - COUNTRIES -
-
- 0 - ACARS -
-
- Local - SOURCE -
-
- -- - SIGNAL -
-
- 00:00:00 - SESSION -
-
- - - - - 📚 History - -
-
-
- STANDBY -
-
--:--:-- UTC
-
-
- -
- -
-
-
-
- ACARS MESSAGES -
- 0 -
-
-
-
-
- Requires separate SDR (VHF ~131 MHz) -
-
-
- - -
-
- -
- -
-
-
-
No ACARS messages
-
Start ACARS to receive aircraft datalink messages
-
-
-
-
-
- -
- - -
-
-
- -
-
- -
-
-
- - - - - -
- -
- DISPLAY -
- - - - - - - - -
-
- - -
- LOCATION -
- - - -
-
- - -
- ADS-B TRACKING -
- - - - - -
-
- - -
- AIRBAND -
- - - - -
- SQ - - VOL - -
- - OFF - -
-
- -
-
- - - - -
-
-
- SQUAWK CODE REFERENCE - -
-
-
- - - - - - - - - -
CodeNameDescription
-
-
-
- - -
-
-
- ★ WATCHLIST - -
-
- - - - -
-
-
No entries. Add callsigns, registrations, or ICAO codes to watch.
-
- -
-
- - - - + + +
+
+
+ 0 + NOW +
+
+ 0 + SEEN +
+
+ 0 + MAX NM +
+
+ - + HIGH FL +
+
+ - + FAST KT +
+
+ - + NEAR NM +
+
+ 0 + COUNTRIES +
+
+ 0 + ACARS +
+
+ Local + SOURCE +
+
+ -- + SIGNAL +
+
+ 00:00:00 + SESSION +
+
+ + + + + 📚 History + +
+
+
+ STANDBY +
+
--:--:-- UTC
+
+
+ +
+ +
+
+
+
+ ACARS MESSAGES +
+ 0 +
+
+
+
+
+ Requires separate SDR (VHF ~131 MHz) +
+
+
+ + +
+
+ +
+ +
+
+
+
No ACARS messages
+
Start ACARS to receive aircraft datalink messages
+
+
+
+
+
+ +
+ + +
+
+
+ +
+
+ +
+
+
+ + + + + +
+ +
+ DISPLAY +
+ + + + + + + + +
+
+ + +
+ LOCATION +
+ + + +
+
+ + +
+ ADS-B TRACKING +
+ + + + + +
+
+ + +
+ AIRBAND +
+ + + + +
+ SQ + + VOL + +
+ + OFF + +
+
+ +
+
+ + + + +
+
+
+ SQUAWK CODE REFERENCE + +
+
+
+ + + + + + + + + +
CodeNameDescription
+
+
+
+ + +
+
+
+ ★ WATCHLIST + +
+
+ + + + +
+
+
No entries. Add callsigns, registrations, or ICAO codes to watch.
+
+ +
+
+ + + + - - - + + + diff --git a/templates/adsb_history.html b/templates/adsb_history.html index 905c4a6..73f850e 100644 --- a/templates/adsb_history.html +++ b/templates/adsb_history.html @@ -466,7 +466,7 @@ if (!points.length) { ctx.fillStyle = 'rgba(156, 163, 175, 0.6)'; - ctx.font = '12px "JetBrains Mono", monospace'; + ctx.font = '12px "Terminus", monospace'; ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2); return; } @@ -474,7 +474,7 @@ const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined); if (!series.length) { ctx.fillStyle = 'rgba(156, 163, 175, 0.6)'; - ctx.font = '12px "JetBrains Mono", monospace'; + ctx.font = '12px "Terminus", monospace'; ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2); return; } @@ -515,7 +515,7 @@ } ctx.fillStyle = 'rgba(226, 232, 240, 0.8)'; - ctx.font = '11px "JetBrains Mono", monospace'; + ctx.font = '11px "Terminus", monospace'; ctx.fillText(`${maxVal} ${unit}`, 12, padding); ctx.fillText(`${minVal} ${unit}`, 12, height - padding); } diff --git a/templates/agents.html b/templates/agents.html index b28be02..5cdd4d8 100644 --- a/templates/agents.html +++ b/templates/agents.html @@ -1,592 +1,592 @@ - - - - - - - iNTERCEPT // Remote Agents - + + + + + + + iNTERCEPT // Remote Agents + - - - - + + + +
- -

- iNTERCEPT // Remote Agents -

+ +

+ iNTERCEPT // Remote Agents +

{% include 'partials/nav.html' with context %} - -
- - -
-

Remote Agents

-
- - -
-

Register New Agent

-
-
-
- - -
-
- - -
-
- - - Required if agent has push mode enabled with API key -
-
-
-
- - -
-
-
- - -
-
-
- - -
- -
- - -
- - + +
+ + +
+

Remote Agents

+
+ + +
+

Register New Agent

+
+
+
+ + +
+
+ + +
+
+ + + Required if agent has push mode enabled with API key +
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+ +
+ + +
+ + - + diff --git a/templates/ais_dashboard.html b/templates/ais_dashboard.html index 98545d7..b481d4d 100644 --- a/templates/ais_dashboard.html +++ b/templates/ais_dashboard.html @@ -1499,7 +1499,7 @@ padding: 4px 8px; border-radius: 4px; font-size: 11px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); cursor: pointer; } .agent-select-sm:focus { diff --git a/templates/index.html b/templates/index.html index 2a3d16b..87f77e7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -290,7 +290,7 @@ ╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝

+ style="font-family: var(--font-mono); font-size: 11px; color: #888; text-align: left; margin: 0;"> root@intercepted:~# sudo access --grant-permission
[sudo] password for user: ********
@@ -989,7 +989,7 @@ style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;"> PACKET LOG

+ style="flex: 1; overflow-y: auto; font-family: var(--font-mono); font-size: 10px; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
Waiting for packets...
@@ -1009,7 +1009,7 @@ STOPPED
+ style="font-size: 52px; font-weight: bold; color: var(--accent-cyan); text-shadow: 0 0 30px var(--accent-cyan); font-family: var(--font-mono); letter-spacing: 3px;"> 118.000
MHz @@ -1237,7 +1237,7 @@ START
+ style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: var(--font-mono); font-weight: bold; color: var(--accent-cyan);"> @@ -1246,7 +1246,7 @@ END + style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: var(--font-mono); font-weight: bold; color: var(--accent-cyan);"> @@ -1312,19 +1312,19 @@
SIGNALS 0
SCANNED 0
CYCLES 0
@@ -4223,7 +4223,7 @@ const infoEl = document.createElement('div'); infoEl.className = 'info-msg'; - infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;'; + infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Terminus", monospace; font-size: 11px; color: #888; word-break: break-all;'; infoEl.textContent = text; output.insertBefore(infoEl, output.firstChild); } @@ -4239,7 +4239,7 @@ const errorEl = document.createElement('div'); errorEl.className = 'error-msg'; - errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;'; + errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Terminus", monospace; font-size: 11px; color: #ff6688; word-break: break-all;'; errorEl.textContent = '⚠ ' + text; output.insertBefore(errorEl, output.firstChild); } @@ -6855,7 +6855,7 @@ // Draw total in center ctx.fillStyle = '#fff'; - ctx.font = 'bold 16px JetBrains Mono'; + ctx.font = 'bold 16px Terminus'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(total, cx, cy); @@ -8625,7 +8625,7 @@ // Label if (el > 0) { ctx.fillStyle = '#444'; - ctx.font = '10px JetBrains Mono'; + ctx.font = '10px Terminus'; ctx.textAlign = 'center'; ctx.fillText(el + '°', cx, cy - r + 12); } @@ -8698,7 +8698,7 @@ // Label ctx.fillStyle = '#fff'; - ctx.font = '11px JetBrains Mono'; + ctx.font = '11px Terminus'; ctx.fillText(pass.satellite, maxX + 10, maxY - 5); } } @@ -12145,7 +12145,7 @@ + style="width: 100%; height: 150px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; font-family: var(--font-mono); font-size: 11px; resize: vertical;"> diff --git a/templates/network_monitor.html b/templates/network_monitor.html index 45121c9..041561a 100644 --- a/templates/network_monitor.html +++ b/templates/network_monitor.html @@ -1,514 +1,514 @@ - - - - - - Network Monitor // INTERCEPT - + + + + + + Network Monitor // INTERCEPT + - - - + + +
{% include 'partials/nav.html' with context %} - -
-
-
- Stream: - Connecting... -
-
- Agents Online: - 0 -
-
- Total Entities: - 0 -
-
- Messages/sec: - 0 -
-
- -
-
-
- Aggregated Data - 0 entries -
-
- - - - - - -
-
-
-
📡
-
Waiting for agent data...
-
Start modes on connected agents to see aggregated data here
-
- - - - - - - - - - - - - -
-
- - -
- - + +
+
+
+ Stream: + Connecting... +
+
+ Agents Online: + 0 +
+
+ Total Entities: + 0 +
+
+ Messages/sec: + 0 +
+
+ +
+
+
+ Aggregated Data + 0 entries +
+
+ + + + + + +
+
+
+
📡
+
Waiting for agent data...
+
Start modes on connected agents to see aggregated data here
+
+ + + + + + + + + + + + + +
+
+ + +
+ + - + diff --git a/templates/partials/modes/listening-post.html b/templates/partials/modes/listening-post.html index f108a94..97e45eb 100644 --- a/templates/partials/modes/listening-post.html +++ b/templates/partials/modes/listening-post.html @@ -19,7 +19,7 @@
Frequency - ---.--- MHz + ---.--- MHz
Signals diff --git a/templates/partials/settings-modal.html b/templates/partials/settings-modal.html index ddfd16b..e4a6385 100644 --- a/templates/partials/settings-modal.html +++ b/templates/partials/settings-modal.html @@ -1,318 +1,318 @@ - -
-
-
-

- - Settings -

- -
- -
- - - - - - -
- - -
-
-
Offline Mode
- -
-
- Enable Offline Mode - Use local assets instead of CDN -
- -
-
- -
-
Asset Sources
- -
-
- JavaScript/CSS Libraries - Leaflet, Chart.js -
- -
- -
-
- Web Fonts - Inter, JetBrains Mono -
- -
-
- -
-
Map Tiles
- -
-
- Tile Provider - Map background imagery -
- -
- - -
- -
-
Local Asset Status
-
-
- Leaflet JS/CSS - Checking... -
-
- Chart.js - Checking... -
-
- Inter Font - Checking... -
-
- JetBrains Mono - Checking... -
-
- -
- -
- Note: Changes to asset sources require a page reload to take effect. - Local assets must be available in /static/vendor/. -
-
- - -
-
-
Observer Location
-

- Set your geographic coordinates for satellite pass predictions and ISS tracking. -

- -
-
- Latitude - Decimal degrees (-90 to 90) -
- -
- -
-
- Longitude - Decimal degrees (-180 to 180) -
- -
- -
- - -
-
- -
-
Current Location
-
-
- Latitude - Not set -
-
- Longitude - Not set -
-
-
- -
- Note: Location is used for ISS pass predictions in SSTV mode and satellite tracking. - Your location is stored locally and never sent to external servers. -
-
- - -
-
-
Visual Preferences
- -
-
- Theme - Color scheme preference -
- -
- -
-
- Animations - Enable visual effects and animations -
- -
-
-
- - -
-
-
Update Status
-
-
- Loading update status... -
-
- -
- -
-
Update Settings
- -
-
- Auto-Check for Updates - Periodically check GitHub for new releases -
- -
-
- -
- Note: Updates are fetched from GitHub and applied via git pull. - Make sure you have git installed and the application is in a git repository. -
-
- - -
-
-
Tool Dependencies
-

- Check which external tools are installed for each mode. - = Installed, - = Missing -

-
-
- Loading dependencies... -
-
-
- -
-
Quick Install (Debian/Ubuntu)
-
-
sudo apt install rtl-sdr multimon-ng rtl-433 aircrack-ng bluez dump1090-mutability hcxdumptool hcxtools
-
pip install skyfield flask
-
-
- Note: ACARS decoding requires acarsdec which must be built from source. - See github.com/TLeconte/acarsdec or run ./setup.sh for automated installation. -
-
-
- - -
-
-
-

iNTERCEPT - Signal Intelligence Platform

-

Version: {{ version }}

-

- A unified web interface for software-defined radio (SDR) tools, - supporting pager decoding, sensor monitoring, aircraft tracking, - WiFi/Bluetooth scanning, and more. -

-

- GitHub Repository -

-
-
- -
-
Support the Project
-

- If you find iNTERCEPT useful, consider supporting its development. -

- -
-
-
-
- + +
+
+
+

+ + Settings +

+ +
+ +
+ + + + + + +
+ + +
+
+
Offline Mode
+ +
+
+ Enable Offline Mode + Use local assets instead of CDN +
+ +
+
+ +
+
Asset Sources
+ +
+
+ JavaScript/CSS Libraries + Leaflet, Chart.js +
+ +
+ +
+
+ Web Fonts + Inter, Terminus +
+ +
+
+ +
+
Map Tiles
+ +
+
+ Tile Provider + Map background imagery +
+ +
+ + +
+ +
+
Local Asset Status
+
+
+ Leaflet JS/CSS + Checking... +
+
+ Chart.js + Checking... +
+
+ Inter Font + Checking... +
+
+ Terminus + Checking... +
+
+ +
+ +
+ Note: Changes to asset sources require a page reload to take effect. + Local assets must be available in /static/vendor/. +
+
+ + +
+
+
Observer Location
+

+ Set your geographic coordinates for satellite pass predictions and ISS tracking. +

+ +
+
+ Latitude + Decimal degrees (-90 to 90) +
+ +
+ +
+
+ Longitude + Decimal degrees (-180 to 180) +
+ +
+ +
+ + +
+
+ +
+
Current Location
+
+
+ Latitude + Not set +
+
+ Longitude + Not set +
+
+
+ +
+ Note: Location is used for ISS pass predictions in SSTV mode and satellite tracking. + Your location is stored locally and never sent to external servers. +
+
+ + +
+
+
Visual Preferences
+ +
+
+ Theme + Color scheme preference +
+ +
+ +
+
+ Animations + Enable visual effects and animations +
+ +
+
+
+ + +
+
+
Update Status
+
+
+ Loading update status... +
+
+ +
+ +
+
Update Settings
+ +
+
+ Auto-Check for Updates + Periodically check GitHub for new releases +
+ +
+
+ +
+ Note: Updates are fetched from GitHub and applied via git pull. + Make sure you have git installed and the application is in a git repository. +
+
+ + +
+
+
Tool Dependencies
+

+ Check which external tools are installed for each mode. + = Installed, + = Missing +

+
+
+ Loading dependencies... +
+
+
+ +
+
Quick Install (Debian/Ubuntu)
+
+
sudo apt install rtl-sdr multimon-ng rtl-433 aircrack-ng bluez dump1090-mutability hcxdumptool hcxtools
+
pip install skyfield flask
+
+
+ Note: ACARS decoding requires acarsdec which must be built from source. + See github.com/TLeconte/acarsdec or run ./setup.sh for automated installation. +
+
+
+ + +
+
+
+

iNTERCEPT - Signal Intelligence Platform

+

Version: {{ version }}

+

+ A unified web interface for software-defined radio (SDR) tools, + supporting pager decoding, sensor monitoring, aircraft tracking, + WiFi/Bluetooth scanning, and more. +

+

+ GitHub Repository +

+
+
+ +
+
Support the Project
+

+ If you find iNTERCEPT useful, consider supporting its development. +

+ +
+
+
+
+ diff --git a/templates/satellite_dashboard.html b/templates/satellite_dashboard.html index 7ac14c6..4d766a1 100644 --- a/templates/satellite_dashboard.html +++ b/templates/satellite_dashboard.html @@ -1,22 +1,22 @@ - - - - - - SATELLITE COMMAND // iNTERCEPT - See the Invisible - - {% if offline_settings.fonts_source == 'local' %} - - {% else %} - - {% endif %} - - {% if offline_settings.assets_source == 'local' %} - - - {% else %} - - + + + + + + SATELLITE COMMAND // iNTERCEPT - See the Invisible + + {% if offline_settings.fonts_source == 'local' %} + + {% else %} + + {% endif %} + + {% if offline_settings.assets_source == 'local' %} + + + {% else %} + + {% endif %} @@ -26,301 +26,301 @@ - -
-
- -
- -
-
- 7 - satellites -
-
- 0 - visible -
-
- 0 - passes -
-
- 0 - best el -
-
-
- -
- Location: - - -
-
-
- TRACKING -
-
--:--:-- UTC
- Main Dashboard -
+ +
+
+ +
+ +
+
+ 7 + satellites +
+
+ 0 + visible +
+
+ 0 + passes +
+
+ 0 + best el +
+
+
+ +
+ Location: + + +
+
+
+ TRACKING +
+
--:--:-- UTC
+ Main Dashboard +
{% set active_mode = 'satellite' %} {% include 'partials/nav.html' with context %} - -
- -
-
- SKY VIEW // POLAR PLOT -
-
-
- -
-
- - -
-
- GROUND TRACK // WORLD VIEW -
-
-
-
-
-
- - - - - -
-
- Lat: - -
-
- Lon: - -
- - -
-
- - - + calculatePasses(); + } + } + + async function calculatePasses() { + const lat = parseFloat(document.getElementById('obsLat').value); + const lon = parseFloat(document.getElementById('obsLon').value); + const satName = satellites[selectedSatellite]?.name || 'Unknown'; + + try { + const response = await fetch('/satellite/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + latitude: lat, + longitude: lon, + hours: 48, + minEl: 5, + satellites: [selectedSatellite] + }) + }); + + const data = await response.json(); + if (data.status === 'success') { + passes = data.passes; + renderPassList(); + updateStats(); + if (passes.length > 0) { + selectPass(0); + } + updateObserverMarker(lat, lon); + + document.getElementById('trackingStatus').textContent = 'TRACKING'; + document.getElementById('trackingDot').style.background = 'var(--accent-green)'; + } else { + document.getElementById('trackingStatus').textContent = 'ERROR'; + document.getElementById('trackingDot').style.background = 'var(--accent-red)'; + } + } catch (err) { + console.error('Pass calculation error:', err); + document.getElementById('trackingStatus').textContent = 'OFFLINE'; + document.getElementById('trackingDot').style.background = 'var(--accent-red)'; + } + } + + function renderPassList() { + const container = document.getElementById('passList'); + const countEl = document.getElementById('passCount'); + + if (passes.length === 0) { + container.innerHTML = '
No passes found
'; + if (countEl) countEl.textContent = ''; + return; + } + + if (countEl) countEl.textContent = `(${passes.length})`; + + container.innerHTML = passes.slice(0, 10).map((pass, idx) => { + const quality = pass.maxEl >= 60 ? 'excellent' : pass.maxEl >= 30 ? 'good' : 'fair'; + const qualityText = pass.maxEl >= 60 ? 'EXCELLENT' : pass.maxEl >= 30 ? 'GOOD' : 'FAIR'; + const time = pass.startTime.split(' ')[1] || pass.startTime; + + return ` +
+
+ ${pass.satellite} + ${qualityText} +
+
+ ${time} + ${pass.maxEl.toFixed(0)}° · ${pass.duration} min +
+
+ `; + }).join(''); + } + + function selectPass(idx) { + selectedPass = idx; + renderPassList(); + + const pass = passes[idx]; + if (!pass) return; + + drawPolarPlot(pass); + updateGroundTrack(pass); + updateTelemetry(pass); + updateRealTimePositions(true); + } + + function drawPolarPlot(pass) { + const canvas = document.getElementById('polarPlot'); + const ctx = canvas.getContext('2d'); + const rect = canvas.parentElement.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height - 20; + + const cx = canvas.width / 2; + const cy = canvas.height / 2; + const radius = Math.max(10, Math.min(cx, cy) - 40); + + ctx.fillStyle = '#0a0a0f'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Elevation rings + ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)'; + ctx.lineWidth = 1; + for (let el = 30; el <= 90; el += 30) { + const r = radius * (1 - el / 90); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + + ctx.fillStyle = 'rgba(0, 212, 255, 0.4)'; + ctx.font = '10px Terminus'; + ctx.fillText(el + '°', cx + 5, cy - r + 12); + } + + // Horizon + ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + + // Cardinal lines + ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)'; + ctx.lineWidth = 1; + for (let az = 0; az < 360; az += 45) { + const angle = (az - 90) * Math.PI / 180; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)); + ctx.stroke(); + } + + // Cardinal labels + ctx.font = 'bold 14px Orbitron'; + const labels = [ + { text: 'N', az: 0, color: '#ff4444' }, + { text: 'E', az: 90, color: '#00d4ff' }, + { text: 'S', az: 180, color: '#00d4ff' }, + { text: 'W', az: 270, color: '#00d4ff' } + ]; + labels.forEach(l => { + const angle = (l.az - 90) * Math.PI / 180; + const x = cx + (radius + 20) * Math.cos(angle); + const y = cy + (radius + 20) * Math.sin(angle); + ctx.fillStyle = l.color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(l.text, x, y); + }); + + // Pass trajectory + if (pass && pass.trajectory) { + ctx.strokeStyle = pass.color || '#00d4ff'; + ctx.lineWidth = 3; + ctx.setLineDash([8, 4]); + ctx.beginPath(); + + let maxElPoint = null; + let maxEl = 0; + + pass.trajectory.forEach((pt, i) => { + const r = radius * (1 - pt.el / 90); + const angle = (pt.az - 90) * Math.PI / 180; + const x = cx + r * Math.cos(angle); + const y = cy + r * Math.sin(angle); + + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + + if (pt.el > maxEl) { + maxEl = pt.el; + maxElPoint = { x, y }; + } + }); + ctx.stroke(); + ctx.setLineDash([]); + + if (maxElPoint) { + ctx.beginPath(); + ctx.arc(maxElPoint.x, maxElPoint.y, 8, 0, Math.PI * 2); + ctx.fillStyle = pass.color || '#00d4ff'; + ctx.fill(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.stroke(); + } + } + } + + function updateGroundTrack(pass) { + if (!groundMap) return; + + if (trackLine) { groundMap.removeLayer(trackLine); trackLine = null; } + if (satMarker) { groundMap.removeLayer(satMarker); satMarker = null; } + if (orbitTrack) { groundMap.removeLayer(orbitTrack); orbitTrack = null; } + + if (pass && pass.groundTrack) { + const segments = []; + let currentSegment = []; + + for (let i = 0; i < pass.groundTrack.length; i++) { + const p = pass.groundTrack[i]; + if (currentSegment.length > 0) { + const prevLon = currentSegment[currentSegment.length - 1][1]; + const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90); + if (crossesAntimeridian) { + if (currentSegment.length >= 1) segments.push(currentSegment); + currentSegment = []; + } + } + currentSegment.push([p.lat, p.lon]); + } + if (currentSegment.length >= 1) segments.push(currentSegment); + + trackLine = L.layerGroup(); + const allCoords = []; + segments.forEach(seg => { + L.polyline(seg, { + color: pass.color || '#00d4ff', + weight: 4, + opacity: 1.0 + }).addTo(trackLine); + allCoords.push(...seg); + }); + trackLine.addTo(groundMap); + + if (pass.currentPos) { + const satIcon = L.divIcon({ + className: 'sat-marker', + html: `
`, + iconSize: [16, 16], + iconAnchor: [8, 8] + }); + + satMarker = L.marker([pass.currentPos.lat, pass.currentPos.lon], { icon: satIcon }) + .addTo(groundMap) + .bindPopup(`${pass.name}
Alt: ${pass.currentPos.alt?.toFixed(0)} km`); + } + + if (allCoords.length > 0) { + groundMap.fitBounds(L.latLngBounds(allCoords), { padding: [30, 30] }); + } + } + } + + function updateObserverMarker(lat, lon) { + if (!groundMap) return; + + if (observerMarker) groundMap.removeLayer(observerMarker); + + // Determine location label + let locationLabel = 'Local Observer'; + if (currentLocationSource && currentLocationSource.startsWith('agent-')) { + const agentId = currentLocationSource.replace('agent-', ''); + const agent = agents.find(a => a.id == agentId); + if (agent) { + locationLabel = agent.name; + } + } + + const obsIcon = L.divIcon({ + className: 'obs-marker', + html: `
`, + iconSize: [12, 12], + iconAnchor: [6, 6] + }); + + observerMarker = L.marker([lat, lon], { icon: obsIcon }) + .addTo(groundMap) + .bindPopup(`${locationLabel}
${lat.toFixed(4)}°, ${lon.toFixed(4)}°`); + } + + function updateStats() { + document.getElementById('statTracked').textContent = Object.keys(satellites).length; + document.getElementById('statPasses').textContent = passes.length; + + const maxEl = passes.reduce((max, p) => Math.max(max, p.maxEl || 0), 0); + document.getElementById('statMaxEl').textContent = maxEl.toFixed(0) + '°'; + } + + function updateTelemetry(pass) { + if (!pass || !pass.currentPos) { + document.getElementById('telLat').textContent = '---.----'; + document.getElementById('telLon').textContent = '---.----'; + document.getElementById('telAlt').textContent = '--- km'; + document.getElementById('telEl').textContent = '--.-'; + document.getElementById('telAz').textContent = '---.-'; + document.getElementById('telDist').textContent = '---- km'; + return; + } + + const pos = pass.currentPos; + document.getElementById('telLat').textContent = (pos.lat || 0).toFixed(4) + '°'; + document.getElementById('telLon').textContent = (pos.lon || 0).toFixed(4) + '°'; + document.getElementById('telAlt').textContent = (pos.alt || 0).toFixed(0) + ' km'; + document.getElementById('telEl').textContent = (pos.el || 0).toFixed(1) + '°'; + document.getElementById('telAz').textContent = (pos.az || 0).toFixed(1) + '°'; + document.getElementById('telDist').textContent = (pos.dist || 0).toFixed(0) + ' km'; + } + + function updateCountdown() { + if (!passes || passes.length === 0) { + document.getElementById('countdownSat').textContent = 'NO PASSES FOUND'; + document.getElementById('countDays').textContent = '--'; + document.getElementById('countHours').textContent = '--'; + document.getElementById('countMins').textContent = '--'; + document.getElementById('countSecs').textContent = '--'; + return; + } + + const now = new Date(); + let nextPass = null; + + for (const pass of passes) { + const start = new Date(pass.startTimeISO); + if (start > now) { + nextPass = pass; + break; + } + } + + if (!nextPass) nextPass = passes[0]; + + document.getElementById('countdownSat').textContent = nextPass.satellite; + + const passTime = new Date(nextPass.startTimeISO); + const diff = passTime - now; + + if (diff <= 0) { + document.getElementById('countDays').textContent = '00'; + document.getElementById('countHours').textContent = '00'; + document.getElementById('countMins').textContent = '00'; + document.getElementById('countSecs').textContent = '00'; + return; + } + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const secs = Math.floor((diff % (1000 * 60)) / 1000); + + document.getElementById('countDays').textContent = days.toString().padStart(2, '0'); + document.getElementById('countHours').textContent = hours.toString().padStart(2, '0'); + document.getElementById('countMins').textContent = mins.toString().padStart(2, '0'); + document.getElementById('countSecs').textContent = secs.toString().padStart(2, '0'); + + const elements = ['countDays', 'countHours', 'countMins', 'countSecs'].map(id => document.getElementById(id)); + if (diff < 60000) { + elements.forEach(el => el.classList.add('active')); + } else { + elements.forEach(el => el.classList.remove('active')); + } + } + + async function updateRealTimePositions(fitBoundsToOrbit = false) { + const lat = parseFloat(document.getElementById('obsLat').value); + const lon = parseFloat(document.getElementById('obsLon').value); + + let targetSatellite = selectedSatellite; + let satColor = satellites[selectedSatellite]?.color || '#00d4ff'; + + if (selectedPass !== null && passes[selectedPass]) { + const pass = passes[selectedPass]; + targetSatellite = pass.satellite; + satColor = pass.color || satColor; + } + + try { + const response = await fetch('/satellite/position', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + latitude: lat, + longitude: lon, + satellites: [targetSatellite], + includeTrack: true + }) + }); + + const data = await response.json(); + if (data.status === 'success' && data.positions.length > 0) { + const pos = data.positions[0]; + + document.getElementById('telLat').textContent = pos.lat.toFixed(4) + '°'; + document.getElementById('telLon').textContent = pos.lon.toFixed(4) + '°'; + document.getElementById('telAlt').textContent = pos.altitude.toFixed(0) + ' km'; + document.getElementById('telEl').textContent = pos.elevation.toFixed(1) + '°'; + document.getElementById('telAz').textContent = pos.azimuth.toFixed(1) + '°'; + document.getElementById('telDist').textContent = pos.distance.toFixed(0) + ' km'; + + document.getElementById('statVisible').textContent = pos.elevation > 0 ? '1' : '0'; + + if (groundMap) { + if (satMarker) groundMap.removeLayer(satMarker); + + const satIcon = L.divIcon({ + className: 'sat-marker-live', + html: `
`, + iconSize: [20, 20], + iconAnchor: [10, 10] + }); + satMarker = L.marker([pos.lat, pos.lon], { icon: satIcon }).addTo(groundMap); + } + + if (pos.track && groundMap) { + if (orbitTrack) groundMap.removeLayer(orbitTrack); + + const segments = []; + let currentSegment = []; + + for (let i = 0; i < pos.track.length; i++) { + const p = pos.track[i]; + if (currentSegment.length > 0) { + const prevLon = currentSegment[currentSegment.length - 1][1]; + const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90); + if (crossesAntimeridian) { + if (currentSegment.length >= 1) segments.push(currentSegment); + currentSegment = []; + } + } + currentSegment.push([p.lat, p.lon]); + } + if (currentSegment.length >= 1) segments.push(currentSegment); + + orbitTrack = L.layerGroup(); + const allOrbitCoords = []; + segments.forEach(seg => { + L.polyline(seg, { + color: satColor, + weight: 2, + opacity: 0.6, + dashArray: '5, 5' + }).addTo(orbitTrack); + allOrbitCoords.push(...seg); + }); + orbitTrack.addTo(groundMap); + + if (fitBoundsToOrbit && allOrbitCoords.length > 0) { + allOrbitCoords.push([lat, lon]); + groundMap.fitBounds(L.latLngBounds(allOrbitCoords), { padding: [30, 30] }); + } + } + + if (selectedPass !== null && passes[selectedPass]) { + drawPolarPlot(passes[selectedPass]); + drawCurrentPositionOnPolar(pos.azimuth, pos.elevation, satColor); + } else { + drawPolarPlotWithPosition(pos.azimuth, pos.elevation, satColor); + } + } + } catch (err) { + console.error('Position update error:', err); + } + } + + function drawPolarPlotWithPosition(az, el, color) { + const canvas = document.getElementById('polarPlot'); + const ctx = canvas.getContext('2d'); + const rect = canvas.parentElement.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height - 20; + + const cx = canvas.width / 2; + const cy = canvas.height / 2; + const radius = Math.max(10, Math.min(cx, cy) - 40); + + ctx.fillStyle = '#0a0a0f'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)'; + ctx.lineWidth = 1; + for (let elRing = 30; elRing <= 90; elRing += 30) { + const r = radius * (1 - elRing / 90); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + + ctx.fillStyle = 'rgba(0, 212, 255, 0.4)'; + ctx.font = '10px Terminus'; + ctx.fillText(elRing + '°', cx + 5, cy - r + 12); + } + + ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + + ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)'; + ctx.lineWidth = 1; + for (let azLine = 0; azLine < 360; azLine += 45) { + const angle = (azLine - 90) * Math.PI / 180; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)); + ctx.stroke(); + } + + ctx.font = 'bold 14px Orbitron'; + const labels = [ + { text: 'N', az: 0, color: '#ff4444' }, + { text: 'E', az: 90, color: '#00d4ff' }, + { text: 'S', az: 180, color: '#00d4ff' }, + { text: 'W', az: 270, color: '#00d4ff' } + ]; + labels.forEach(l => { + const angle = (l.az - 90) * Math.PI / 180; + const x = cx + (radius + 20) * Math.cos(angle); + const y = cy + (radius + 20) * Math.sin(angle); + ctx.fillStyle = l.color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(l.text, x, y); + }); + + if (el > -5) { + const posEl = Math.max(0, el); + const r = radius * (1 - posEl / 90); + const angle = (az - 90) * Math.PI / 180; + const x = cx + r * Math.cos(angle); + const y = cy + r * Math.sin(angle); + + const gradient = ctx.createRadialGradient(x, y, 0, x, y, 25); + gradient.addColorStop(0, color); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.globalAlpha = 0.4; + ctx.beginPath(); + ctx.arc(x, y, 25, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + + ctx.beginPath(); + ctx.arc(x, y, 10, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.font = 'bold 11px Orbitron'; + ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; + ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20); + + ctx.font = '10px Terminus'; + ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444'; + ctx.fillText(el.toFixed(1) + '°', x, y + 25); + } else { + ctx.font = '12px Rajdhani'; + ctx.fillStyle = '#ff4444'; + ctx.textAlign = 'center'; + ctx.fillText('BELOW HORIZON', cx, cy + radius + 35); + } + } + + function drawCurrentPositionOnPolar(az, el, color) { + const canvas = document.getElementById('polarPlot'); + const ctx = canvas.getContext('2d'); + + const cx = canvas.width / 2; + const cy = canvas.height / 2; + const radius = Math.max(10, Math.min(cx, cy) - 40); + + if (el > -5) { + const posEl = Math.max(0, el); + const r = radius * (1 - posEl / 90); + const angle = (az - 90) * Math.PI / 180; + const x = cx + r * Math.cos(angle); + const y = cy + r * Math.sin(angle); + + const gradient = ctx.createRadialGradient(x, y, 0, x, y, 25); + gradient.addColorStop(0, color); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.globalAlpha = 0.4; + ctx.beginPath(); + ctx.arc(x, y, 25, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + + ctx.beginPath(); + ctx.arc(x, y, 10, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.font = 'bold 11px Orbitron'; + ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; + ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20); + + ctx.font = '10px Terminus'; + ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444'; + ctx.fillText(el.toFixed(1) + '°', x, y + 25); + } + } + - +