mirror of
https://github.com/smittix/intercept.git
synced 2026-07-25 17:28:11 -07:00
Merge branch 'codex/new-ui'
# Conflicts: # static/css/index.css
This commit is contained in:
@@ -282,6 +282,10 @@ def require_login():
|
|||||||
# Routes that don't require login (to avoid infinite redirect loop)
|
# Routes that don't require login (to avoid infinite redirect loop)
|
||||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||||
|
|
||||||
|
# Allow audio streaming endpoints without session auth
|
||||||
|
if request.path.startswith('/listening/audio/'):
|
||||||
|
return None
|
||||||
|
|
||||||
# Controller API endpoints use API key auth, not session auth
|
# Controller API endpoints use API key auth, not session auth
|
||||||
# Allow agent push/pull endpoints without session login
|
# Allow agent push/pull endpoints without session login
|
||||||
if request.path.startswith('/controller/'):
|
if request.path.startswith('/controller/'):
|
||||||
|
|||||||
@@ -0,0 +1,608 @@
|
|||||||
|
# iNTERCEPT UI Guide
|
||||||
|
|
||||||
|
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Design Tokens](#design-tokens)
|
||||||
|
2. [Base Templates](#base-templates)
|
||||||
|
3. [Navigation](#navigation)
|
||||||
|
4. [Components](#components)
|
||||||
|
5. [Adding a New Module Page](#adding-a-new-module-page)
|
||||||
|
6. [Adding a New Dashboard](#adding-a-new-dashboard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Tokens
|
||||||
|
|
||||||
|
All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Backgrounds (layered depth) */
|
||||||
|
--bg-primary: #0a0c10; /* Darkest - page background */
|
||||||
|
--bg-secondary: #0f1218; /* Panels, sidebars */
|
||||||
|
--bg-tertiary: #151a23; /* Cards, elevated elements */
|
||||||
|
--bg-card: #121620; /* Card backgrounds */
|
||||||
|
--bg-elevated: #1a202c; /* Hover states, modals */
|
||||||
|
|
||||||
|
/* Accent Colors */
|
||||||
|
--accent-cyan: #4a9eff; /* Primary action color */
|
||||||
|
--accent-green: #22c55e; /* Success, online status */
|
||||||
|
--accent-red: #ef4444; /* Error, danger, stop */
|
||||||
|
--accent-orange: #f59e0b; /* Warning */
|
||||||
|
--accent-amber: #d4a853; /* Secondary highlight */
|
||||||
|
|
||||||
|
/* Text Hierarchy */
|
||||||
|
--text-primary: #e8eaed; /* Main content */
|
||||||
|
--text-secondary: #9ca3af; /* Secondary content */
|
||||||
|
--text-dim: #4b5563; /* Disabled, placeholder */
|
||||||
|
--text-muted: #374151; /* Barely visible */
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
--status-online: #22c55e;
|
||||||
|
--status-warning: #f59e0b;
|
||||||
|
--status-error: #ef4444;
|
||||||
|
--status-offline: #6b7280;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Font Families */
|
||||||
|
--font-sans: 'Inter', -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--text-xs: 10px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-lg: 16px;
|
||||||
|
--text-xl: 18px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
--text-4xl: 30px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
|
||||||
|
```css
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Light Theme
|
||||||
|
|
||||||
|
The design system supports light/dark themes via `data-theme` attribute:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<html data-theme="dark"> <!-- or "light" -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Toggle with JavaScript:
|
||||||
|
```javascript
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base Templates
|
||||||
|
|
||||||
|
### `templates/layout/base.html`
|
||||||
|
|
||||||
|
The main base template for standard pages. Use for pages with sidebar + content layout.
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}My Page Title{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/my-page.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{% set active_mode = 'mymode' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
<div class="app-sidebar">
|
||||||
|
<!-- Sidebar content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-container">
|
||||||
|
<h1>Page Title</h1>
|
||||||
|
<!-- Page content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Page-specific JavaScript
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `templates/layout/base_dashboard.html`
|
||||||
|
|
||||||
|
Extended base for full-screen dashboards (maps, visualizations).
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base_dashboard.html' %}
|
||||||
|
|
||||||
|
{% set active_mode = 'mydashboard' %}
|
||||||
|
|
||||||
|
{% block dashboard_title %}MY DASHBOARD{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{{ super() }}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/my_dashboard.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block stats_strip %}
|
||||||
|
<div class="stats-strip">
|
||||||
|
<!-- Stats bar content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block dashboard_content %}
|
||||||
|
<div class="dashboard-map-container">
|
||||||
|
<!-- Main visualization -->
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-sidebar">
|
||||||
|
<!-- Sidebar panels -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### Including Navigation
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% set active_mode = 'pager' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valid `active_mode` Values
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `pager` | Pager decoding |
|
||||||
|
| `sensor` | 433MHz sensors |
|
||||||
|
| `rtlamr` | Utility meters |
|
||||||
|
| `adsb` | Aircraft tracking |
|
||||||
|
| `ais` | Vessel tracking |
|
||||||
|
| `aprs` | Amateur radio |
|
||||||
|
| `wifi` | WiFi scanning |
|
||||||
|
| `bluetooth` | Bluetooth scanning |
|
||||||
|
| `tscm` | Counter-surveillance |
|
||||||
|
| `satellite` | Satellite tracking |
|
||||||
|
| `sstv` | ISS SSTV |
|
||||||
|
| `listening` | Listening post |
|
||||||
|
| `spystations` | Spy stations |
|
||||||
|
| `meshtastic` | Mesh networking |
|
||||||
|
|
||||||
|
### Navigation Groups
|
||||||
|
|
||||||
|
The navigation is organized into groups:
|
||||||
|
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
||||||
|
- **Wireless**: WiFi, Bluetooth
|
||||||
|
- **Security**: TSCM
|
||||||
|
- **Space**: Satellite, ISS SSTV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Card / Panel
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
|
||||||
|
<p>Panel content here</p>
|
||||||
|
{% endcall %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
```html
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>PANEL TITLE</span>
|
||||||
|
<div class="panel-indicator active"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<p>Content here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% include 'components/empty_state.html' with context %}
|
||||||
|
{# Or with variables: #}
|
||||||
|
{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
|
||||||
|
{% include 'components/empty_state.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading State
|
||||||
|
|
||||||
|
```html
|
||||||
|
{# Inline spinner #}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
|
||||||
|
{# With text #}
|
||||||
|
{% with text='Loading data...', size='lg' %}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{# Full overlay #}
|
||||||
|
{% with overlay=true, text='Please wait...' %}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Badge
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% with status='online', text='Connected', id='connectionStatus' %}
|
||||||
|
{% include 'components/status_badge.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status values: `online`, `offline`, `warning`, `error`, `inactive`
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Primary action -->
|
||||||
|
<button class="btn btn-primary">Start Tracking</button>
|
||||||
|
|
||||||
|
<!-- Secondary action -->
|
||||||
|
<button class="btn btn-secondary">Cancel</button>
|
||||||
|
|
||||||
|
<!-- Danger action -->
|
||||||
|
<button class="btn btn-danger">Stop</button>
|
||||||
|
|
||||||
|
<!-- Ghost/subtle -->
|
||||||
|
<button class="btn btn-ghost">Settings</button>
|
||||||
|
|
||||||
|
<!-- Sizes -->
|
||||||
|
<button class="btn btn-primary btn-sm">Small</button>
|
||||||
|
<button class="btn btn-primary btn-lg">Large</button>
|
||||||
|
|
||||||
|
<!-- Icon button -->
|
||||||
|
<button class="btn btn-icon btn-secondary">
|
||||||
|
<span class="icon">...</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="badge">Default</span>
|
||||||
|
<span class="badge badge-primary">Primary</span>
|
||||||
|
<span class="badge badge-success">Online</span>
|
||||||
|
<span class="badge badge-warning">Warning</span>
|
||||||
|
<span class="badge badge-danger">Error</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Groups
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="frequency">Frequency (MHz)</label>
|
||||||
|
<input type="text" id="frequency" value="153.350">
|
||||||
|
<span class="form-help">Enter frequency in MHz</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="gain">Gain</label>
|
||||||
|
<select id="gain">
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="30">30 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="checkbox" id="alerts">
|
||||||
|
<span>Enable alerts</span>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats Strip
|
||||||
|
|
||||||
|
Used in dashboards for horizontal statistics display:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="stats-strip">
|
||||||
|
<div class="stats-strip-inner">
|
||||||
|
<div class="strip-stat">
|
||||||
|
<span class="strip-value" id="count">0</span>
|
||||||
|
<span class="strip-label">COUNT</span>
|
||||||
|
</div>
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<div class="strip-status">
|
||||||
|
<div class="status-dot active" id="statusDot"></div>
|
||||||
|
<span id="statusText">TRACKING</span>
|
||||||
|
</div>
|
||||||
|
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Module Page
|
||||||
|
|
||||||
|
### 1. Create the Route
|
||||||
|
|
||||||
|
In `routes/mymodule.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
|
||||||
|
|
||||||
|
@mymodule_bp.route('/dashboard')
|
||||||
|
def dashboard():
|
||||||
|
return render_template('mymodule_dashboard.html',
|
||||||
|
offline_settings=get_offline_settings())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register the Blueprint
|
||||||
|
|
||||||
|
In `routes/__init__.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from routes.mymodule import mymodule_bp
|
||||||
|
app.register_blueprint(mymodule_bp)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create the Template
|
||||||
|
|
||||||
|
Option A: Simple page extending base.html
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
{% set active_mode = 'mymodule' %}
|
||||||
|
|
||||||
|
{% block title %}My Module{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Your content -->
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Option B: Full-screen dashboard
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base_dashboard.html' %}
|
||||||
|
{% set active_mode = 'mymodule' %}
|
||||||
|
|
||||||
|
{% block dashboard_title %}MY MODULE{% endblock %}
|
||||||
|
|
||||||
|
{% block dashboard_content %}
|
||||||
|
<!-- Your dashboard content -->
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add to Navigation
|
||||||
|
|
||||||
|
In `templates/partials/nav.html`, add your module to the appropriate group:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||||
|
onclick="switchMode('mymodule')">
|
||||||
|
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||||
|
<span class="nav-label">My Module</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if it's a dashboard link:
|
||||||
|
```html
|
||||||
|
<a href="/mymodule/dashboard"
|
||||||
|
class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||||
|
style="text-decoration: none;">
|
||||||
|
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||||
|
<span class="nav-label">My Module</span>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Stylesheet
|
||||||
|
|
||||||
|
In `static/css/mymodule.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/**
|
||||||
|
* My Module Styles
|
||||||
|
*/
|
||||||
|
@import url('./core/variables.css');
|
||||||
|
|
||||||
|
/* Your styles using design tokens */
|
||||||
|
.mymodule-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Dashboard
|
||||||
|
|
||||||
|
For full-screen dashboards like ADSB, AIS, or Satellite:
|
||||||
|
|
||||||
|
### 1. Create the Template
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MY DASHBOARD // iNTERCEPT</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
|
|
||||||
|
<!-- Design tokens (required) -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- External libraries if needed -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Dashboard styles -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/mydashboard.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Background effects -->
|
||||||
|
<div class="radar-bg"></div>
|
||||||
|
<div class="scanline"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<a href="/" style="color: inherit; text-decoration: none;">
|
||||||
|
MY DASHBOARD
|
||||||
|
<span>// iNTERCEPT</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar">
|
||||||
|
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
|
||||||
|
<a href="/" class="back-link">Main Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Unified Navigation -->
|
||||||
|
{% set active_mode = 'mydashboard' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
|
||||||
|
<!-- Stats Strip -->
|
||||||
|
<div class="stats-strip">
|
||||||
|
<!-- Stats content -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Dashboard Content -->
|
||||||
|
<main class="dashboard">
|
||||||
|
<!-- Your dashboard layout -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dashboard JavaScript
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create the Stylesheet
|
||||||
|
|
||||||
|
```css
|
||||||
|
/**
|
||||||
|
* My Dashboard Styles
|
||||||
|
*/
|
||||||
|
@import url('./core/variables.css');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Dashboard-specific aliases */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
--bg-card: var(--bg-tertiary);
|
||||||
|
--grid-line: rgba(74, 158, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Your dashboard styles */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### DO
|
||||||
|
|
||||||
|
- Use design tokens for all colors, spacing, and typography
|
||||||
|
- Include the nav partial on all pages for consistent navigation
|
||||||
|
- Set `active_mode` before including the nav partial
|
||||||
|
- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
|
||||||
|
- Support both light and dark themes
|
||||||
|
- Test on mobile viewports
|
||||||
|
|
||||||
|
### DON'T
|
||||||
|
|
||||||
|
- Hardcode color values - use CSS variables
|
||||||
|
- Create new color variations without adding to tokens
|
||||||
|
- Duplicate navigation markup - use the partial
|
||||||
|
- Skip the favicon and design tokens imports
|
||||||
|
- Use inline styles for layout (use utility classes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
templates/
|
||||||
|
├── layout/
|
||||||
|
│ ├── base.html # Standard page base
|
||||||
|
│ └── base_dashboard.html # Dashboard page base
|
||||||
|
├── partials/
|
||||||
|
│ ├── nav.html # Unified navigation
|
||||||
|
│ ├── page_header.html # Page title component
|
||||||
|
│ └── settings-modal.html # Settings modal
|
||||||
|
├── components/
|
||||||
|
│ ├── card.html # Panel/card component
|
||||||
|
│ ├── empty_state.html # Empty state placeholder
|
||||||
|
│ ├── loading.html # Loading spinner
|
||||||
|
│ ├── stats_strip.html # Stats bar component
|
||||||
|
│ └── status_badge.html # Status indicator
|
||||||
|
├── index.html # Main dashboard
|
||||||
|
├── adsb_dashboard.html # Aircraft tracking
|
||||||
|
├── ais_dashboard.html # Vessel tracking
|
||||||
|
└── satellite_dashboard.html # Satellite tracking
|
||||||
|
|
||||||
|
static/css/
|
||||||
|
├── core/
|
||||||
|
│ ├── variables.css # Design tokens
|
||||||
|
│ ├── base.css # Reset & typography
|
||||||
|
│ ├── components.css # Component styles
|
||||||
|
│ └── layout.css # Layout styles
|
||||||
|
├── index.css # Main dashboard styles
|
||||||
|
├── adsb_dashboard.css # Aircraft dashboard
|
||||||
|
├── ais_dashboard.css # Vessel dashboard
|
||||||
|
├── satellite_dashboard.css # Satellite dashboard
|
||||||
|
└── responsive.css # Responsive breakpoints
|
||||||
|
```
|
||||||
@@ -229,7 +229,11 @@ def init_audio_websocket(app: Flask):
|
|||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "timed out" not in str(e).lower():
|
msg = str(e).lower()
|
||||||
|
if "connection closed" in msg:
|
||||||
|
logger.info("WebSocket closed by client")
|
||||||
|
break
|
||||||
|
if "timed out" not in msg:
|
||||||
logger.error(f"WebSocket receive error: {e}")
|
logger.error(f"WebSocket receive error: {e}")
|
||||||
|
|
||||||
# Stream audio data if active
|
# Stream audio data if active
|
||||||
|
|||||||
+505
-42
@@ -16,6 +16,7 @@ from typing import Generator, Optional, List, Dict
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -47,18 +48,23 @@ scanner_running = False
|
|||||||
scanner_lock = threading.Lock()
|
scanner_lock = threading.Lock()
|
||||||
scanner_paused = False
|
scanner_paused = False
|
||||||
scanner_current_freq = 0.0
|
scanner_current_freq = 0.0
|
||||||
|
scanner_active_device: Optional[int] = None
|
||||||
|
listening_active_device: Optional[int] = None
|
||||||
|
scanner_power_process: Optional[subprocess.Popen] = None
|
||||||
scanner_config = {
|
scanner_config = {
|
||||||
'start_freq': 88.0,
|
'start_freq': 88.0,
|
||||||
'end_freq': 108.0,
|
'end_freq': 108.0,
|
||||||
'step': 0.1,
|
'step': 0.1,
|
||||||
'modulation': 'wfm',
|
'modulation': 'wfm',
|
||||||
'squelch': 20,
|
'squelch': 0,
|
||||||
'dwell_time': 10.0, # Seconds to stay on active frequency
|
'dwell_time': 10.0, # Seconds to stay on active frequency
|
||||||
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
||||||
'device': 0,
|
'device': 0,
|
||||||
'gain': 40,
|
'gain': 40,
|
||||||
'bias_t': False, # Bias-T power for external LNA
|
'bias_t': False, # Bias-T power for external LNA
|
||||||
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
||||||
|
'scan_method': 'power', # power (rtl_power) or classic (rtl_fm hop)
|
||||||
|
'snr_threshold': 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Activity log
|
# Activity log
|
||||||
@@ -79,6 +85,11 @@ def find_rtl_fm() -> str | None:
|
|||||||
return shutil.which('rtl_fm')
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
|
def find_rtl_power() -> str | None:
|
||||||
|
"""Find rtl_power binary."""
|
||||||
|
return shutil.which('rtl_power')
|
||||||
|
|
||||||
|
|
||||||
def find_rx_fm() -> str | None:
|
def find_rx_fm() -> str | None:
|
||||||
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
||||||
return shutil.which('rx_fm')
|
return shutil.which('rx_fm')
|
||||||
@@ -161,7 +172,9 @@ def scanner_loop():
|
|||||||
scanner_queue.put_nowait({
|
scanner_queue.put_nowait({
|
||||||
'type': 'freq_change',
|
'type': 'freq_change',
|
||||||
'frequency': current_freq,
|
'frequency': current_freq,
|
||||||
'scanning': not signal_detected
|
'scanning': not signal_detected,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
@@ -238,11 +251,14 @@ def scanner_loop():
|
|||||||
if mod == 'wfm':
|
if mod == 'wfm':
|
||||||
# WFM: threshold 500-10000 based on squelch
|
# WFM: threshold 500-10000 based on squelch
|
||||||
threshold = 500 + (squelch * 95)
|
threshold = 500 + (squelch * 95)
|
||||||
|
min_threshold = 1500
|
||||||
else:
|
else:
|
||||||
# AM/NFM: threshold 300-6500 based on squelch
|
# AM/NFM: threshold 300-6500 based on squelch
|
||||||
threshold = 300 + (squelch * 62)
|
threshold = 300 + (squelch * 62)
|
||||||
|
min_threshold = 900
|
||||||
|
|
||||||
audio_detected = rms > threshold
|
effective_threshold = max(threshold, min_threshold)
|
||||||
|
audio_detected = rms > effective_threshold
|
||||||
|
|
||||||
# Send level info to clients
|
# Send level info to clients
|
||||||
try:
|
try:
|
||||||
@@ -250,8 +266,10 @@ def scanner_loop():
|
|||||||
'type': 'scan_update',
|
'type': 'scan_update',
|
||||||
'frequency': current_freq,
|
'frequency': current_freq,
|
||||||
'level': int(rms),
|
'level': int(rms),
|
||||||
'threshold': int(threshold) if 'threshold' in dir() else 0,
|
'threshold': int(effective_threshold) if 'effective_threshold' in dir() else 0,
|
||||||
'detected': audio_detected
|
'detected': audio_detected,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
@@ -268,15 +286,19 @@ def scanner_loop():
|
|||||||
# Start audio streaming for user
|
# Start audio streaming for user
|
||||||
_start_audio_stream(current_freq, mod)
|
_start_audio_stream(current_freq, mod)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
scanner_queue.put_nowait({
|
scanner_queue.put_nowait({
|
||||||
'type': 'signal_found',
|
'type': 'signal_found',
|
||||||
'frequency': current_freq,
|
'frequency': current_freq,
|
||||||
'modulation': mod,
|
'modulation': mod,
|
||||||
'audio_streaming': True
|
'audio_streaming': True,
|
||||||
})
|
'level': int(rms),
|
||||||
except queue.Full:
|
'threshold': int(effective_threshold),
|
||||||
pass
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
# Check for skip signal
|
# Check for skip signal
|
||||||
if scanner_skip_signal:
|
if scanner_skip_signal:
|
||||||
@@ -305,6 +327,26 @@ def scanner_loop():
|
|||||||
|
|
||||||
last_signal_time = time.time()
|
last_signal_time = time.time()
|
||||||
|
|
||||||
|
# After dwell, move on to keep scanning
|
||||||
|
if scanner_running and not scanner_skip_signal:
|
||||||
|
signal_detected = False
|
||||||
|
_stop_audio_stream()
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_lost',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
current_freq += step_mhz
|
||||||
|
if current_freq > scanner_config['end_freq']:
|
||||||
|
current_freq = scanner_config['start_freq']
|
||||||
|
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
|
||||||
|
time.sleep(scanner_config['scan_delay'])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# No signal at this frequency
|
# No signal at this frequency
|
||||||
if signal_detected:
|
if signal_detected:
|
||||||
@@ -346,6 +388,241 @@ def scanner_loop():
|
|||||||
logger.info("Scanner thread stopped")
|
logger.info("Scanner thread stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def scanner_loop_power():
|
||||||
|
"""Power sweep scanner using rtl_power to detect peaks."""
|
||||||
|
global scanner_running, scanner_paused, scanner_current_freq, scanner_power_process
|
||||||
|
|
||||||
|
logger.info("Power sweep scanner thread started")
|
||||||
|
add_activity_log('scanner_start', scanner_config['start_freq'],
|
||||||
|
f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
|
||||||
|
|
||||||
|
rtl_power_path = find_rtl_power()
|
||||||
|
if not rtl_power_path:
|
||||||
|
logger.error("rtl_power not found")
|
||||||
|
add_activity_log('error', 0, 'rtl_power not found')
|
||||||
|
scanner_running = False
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while scanner_running:
|
||||||
|
if scanner_paused:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_mhz = scanner_config['start_freq']
|
||||||
|
end_mhz = scanner_config['end_freq']
|
||||||
|
step_khz = scanner_config['step']
|
||||||
|
gain = scanner_config['gain']
|
||||||
|
device = scanner_config['device']
|
||||||
|
squelch = scanner_config['squelch']
|
||||||
|
mod = scanner_config['modulation']
|
||||||
|
|
||||||
|
# Configure sweep
|
||||||
|
bin_hz = max(1000, int(step_khz * 1000))
|
||||||
|
start_hz = int(start_mhz * 1e6)
|
||||||
|
end_hz = int(end_mhz * 1e6)
|
||||||
|
# Integration time per sweep (seconds)
|
||||||
|
integration = max(0.3, min(1.0, scanner_config.get('scan_delay', 0.5)))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
rtl_power_path,
|
||||||
|
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
||||||
|
'-i', f'{integration}',
|
||||||
|
'-1',
|
||||||
|
'-g', str(gain),
|
||||||
|
'-d', str(device),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||||
|
scanner_power_process = proc
|
||||||
|
stdout, _ = proc.communicate(timeout=15)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
stdout = b''
|
||||||
|
finally:
|
||||||
|
scanner_power_process = None
|
||||||
|
|
||||||
|
if not scanner_running:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not stdout:
|
||||||
|
add_activity_log('error', start_mhz, 'Power sweep produced no data')
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': end_mhz,
|
||||||
|
'level': 0,
|
||||||
|
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
|
||||||
|
'detected': False,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = stdout.decode(errors='ignore').splitlines()
|
||||||
|
segments = []
|
||||||
|
for line in lines:
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
# Find start_hz token
|
||||||
|
start_idx = None
|
||||||
|
for i, tok in enumerate(parts):
|
||||||
|
try:
|
||||||
|
val = float(tok)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if val > 1e5:
|
||||||
|
start_idx = i
|
||||||
|
break
|
||||||
|
if start_idx is None or len(parts) < start_idx + 6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sweep_start = float(parts[start_idx])
|
||||||
|
sweep_end = float(parts[start_idx + 1])
|
||||||
|
sweep_bin = float(parts[start_idx + 2])
|
||||||
|
raw_values = []
|
||||||
|
for v in parts[start_idx + 3:]:
|
||||||
|
try:
|
||||||
|
raw_values.append(float(v))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
# rtl_power may include a samples field before the power list
|
||||||
|
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
||||||
|
raw_values = raw_values[1:]
|
||||||
|
bin_values = raw_values
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not bin_values:
|
||||||
|
continue
|
||||||
|
|
||||||
|
segments.append((sweep_start, sweep_end, sweep_bin, bin_values))
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
add_activity_log('error', start_mhz, 'Power sweep bins missing')
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': end_mhz,
|
||||||
|
'level': 0,
|
||||||
|
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
|
||||||
|
'detected': False,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process segments in ascending frequency order to avoid backtracking in UI
|
||||||
|
segments.sort(key=lambda s: s[0])
|
||||||
|
total_bins = sum(len(seg[3]) for seg in segments)
|
||||||
|
if total_bins <= 0:
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
segment_offset = 0
|
||||||
|
|
||||||
|
for sweep_start, sweep_end, sweep_bin, bin_values in segments:
|
||||||
|
# Noise floor (median)
|
||||||
|
sorted_vals = sorted(bin_values)
|
||||||
|
mid = len(sorted_vals) // 2
|
||||||
|
noise_floor = sorted_vals[mid]
|
||||||
|
|
||||||
|
# SNR threshold (dB)
|
||||||
|
snr_threshold = float(scanner_config.get('snr_threshold', 12))
|
||||||
|
|
||||||
|
# Emit progress updates (throttled)
|
||||||
|
emit_stride = max(1, len(bin_values) // 60)
|
||||||
|
for idx, val in enumerate(bin_values):
|
||||||
|
if idx % emit_stride != 0 and idx != len(bin_values) - 1:
|
||||||
|
continue
|
||||||
|
freq_hz = sweep_start + sweep_bin * idx
|
||||||
|
scanner_current_freq = freq_hz / 1e6
|
||||||
|
snr = val - noise_floor
|
||||||
|
level = int(max(0, snr) * 100)
|
||||||
|
threshold = int(snr_threshold * 100)
|
||||||
|
progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': scanner_current_freq,
|
||||||
|
'level': level,
|
||||||
|
'threshold': threshold,
|
||||||
|
'detected': snr >= snr_threshold,
|
||||||
|
'progress': progress,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
segment_offset += len(bin_values)
|
||||||
|
|
||||||
|
# Detect peaks (clusters above threshold)
|
||||||
|
peaks = []
|
||||||
|
in_cluster = False
|
||||||
|
peak_idx = None
|
||||||
|
peak_val = None
|
||||||
|
for idx, val in enumerate(bin_values):
|
||||||
|
snr = val - noise_floor
|
||||||
|
if snr >= snr_threshold:
|
||||||
|
if not in_cluster:
|
||||||
|
in_cluster = True
|
||||||
|
peak_idx = idx
|
||||||
|
peak_val = val
|
||||||
|
else:
|
||||||
|
if val > peak_val:
|
||||||
|
peak_val = val
|
||||||
|
peak_idx = idx
|
||||||
|
else:
|
||||||
|
if in_cluster and peak_idx is not None:
|
||||||
|
peaks.append((peak_idx, peak_val))
|
||||||
|
in_cluster = False
|
||||||
|
peak_idx = None
|
||||||
|
peak_val = None
|
||||||
|
if in_cluster and peak_idx is not None:
|
||||||
|
peaks.append((peak_idx, peak_val))
|
||||||
|
|
||||||
|
for idx, val in peaks:
|
||||||
|
freq_hz = sweep_start + sweep_bin * (idx + 0.5)
|
||||||
|
freq_mhz = freq_hz / 1e6
|
||||||
|
snr = val - noise_floor
|
||||||
|
level = int(max(0, snr) * 100)
|
||||||
|
threshold = int(snr_threshold * 100)
|
||||||
|
add_activity_log('signal_found', freq_mhz,
|
||||||
|
f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_found',
|
||||||
|
'frequency': freq_mhz,
|
||||||
|
'modulation': mod,
|
||||||
|
'audio_streaming': False,
|
||||||
|
'level': level,
|
||||||
|
'threshold': threshold,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
|
||||||
|
time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Power sweep scanner error: {e}")
|
||||||
|
finally:
|
||||||
|
scanner_running = False
|
||||||
|
add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
|
||||||
|
logger.info("Power sweep scanner thread stopped")
|
||||||
|
|
||||||
|
|
||||||
def _start_audio_stream(frequency: float, modulation: str):
|
def _start_audio_stream(frequency: float, modulation: str):
|
||||||
"""Start audio streaming at given frequency."""
|
"""Start audio streaming at given frequency."""
|
||||||
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
||||||
@@ -428,14 +705,17 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
ffmpeg_path,
|
ffmpeg_path,
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'error',
|
'-loglevel', 'error',
|
||||||
|
'-fflags', 'nobuffer',
|
||||||
|
'-flags', 'low_delay',
|
||||||
|
'-probesize', '32',
|
||||||
|
'-analyzeduration', '0',
|
||||||
'-f', 's16le',
|
'-f', 's16le',
|
||||||
'-ar', str(resample_rate),
|
'-ar', str(resample_rate),
|
||||||
'-ac', '1',
|
'-ac', '1',
|
||||||
'-i', 'pipe:0',
|
'-i', 'pipe:0',
|
||||||
'-acodec', 'libmp3lame',
|
'-acodec', 'pcm_s16le',
|
||||||
'-b:a', '128k',
|
|
||||||
'-ar', '44100',
|
'-ar', '44100',
|
||||||
'-f', 'mp3',
|
'-f', 'wav',
|
||||||
'pipe:1'
|
'pipe:1'
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -477,6 +757,14 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}")
|
logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Validate that audio is producing data quickly
|
||||||
|
try:
|
||||||
|
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
||||||
|
if not ready:
|
||||||
|
logger.warning("Audio pipeline produced no data in startup window")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Audio startup check failed: {e}")
|
||||||
|
|
||||||
audio_running = True
|
audio_running = True
|
||||||
audio_frequency = frequency
|
audio_frequency = frequency
|
||||||
audio_modulation = modulation
|
audio_modulation = modulation
|
||||||
@@ -537,6 +825,7 @@ def _stop_audio_stream_internal():
|
|||||||
def check_tools() -> Response:
|
def check_tools() -> Response:
|
||||||
"""Check for required tools."""
|
"""Check for required tools."""
|
||||||
rtl_fm = find_rtl_fm()
|
rtl_fm = find_rtl_fm()
|
||||||
|
rtl_power = find_rtl_power()
|
||||||
rx_fm = find_rx_fm()
|
rx_fm = find_rx_fm()
|
||||||
ffmpeg = find_ffmpeg()
|
ffmpeg = find_ffmpeg()
|
||||||
|
|
||||||
@@ -550,6 +839,7 @@ def check_tools() -> Response:
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'rtl_fm': rtl_fm is not None,
|
'rtl_fm': rtl_fm is not None,
|
||||||
|
'rtl_power': rtl_power is not None,
|
||||||
'rx_fm': rx_fm is not None,
|
'rx_fm': rx_fm is not None,
|
||||||
'ffmpeg': ffmpeg is not None,
|
'ffmpeg': ffmpeg is not None,
|
||||||
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
||||||
@@ -560,7 +850,7 @@ def check_tools() -> Response:
|
|||||||
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
||||||
def start_scanner() -> Response:
|
def start_scanner() -> Response:
|
||||||
"""Start the frequency scanner."""
|
"""Start the frequency scanner."""
|
||||||
global scanner_thread, scanner_running, scanner_config
|
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
|
||||||
|
|
||||||
with scanner_lock:
|
with scanner_lock:
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
@@ -569,6 +859,13 @@ def start_scanner() -> Response:
|
|||||||
'message': 'Scanner already running'
|
'message': 'Scanner already running'
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
|
# Clear stale queue entries so UI updates immediately
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
scanner_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
# Update scanner config
|
# Update scanner config
|
||||||
@@ -577,13 +874,16 @@ def start_scanner() -> Response:
|
|||||||
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||||
scanner_config['step'] = float(data.get('step', 0.1))
|
scanner_config['step'] = float(data.get('step', 0.1))
|
||||||
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
|
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
|
||||||
scanner_config['squelch'] = int(data.get('squelch', 20))
|
scanner_config['squelch'] = int(data.get('squelch', 0))
|
||||||
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
|
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
|
||||||
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
||||||
scanner_config['device'] = int(data.get('device', 0))
|
scanner_config['device'] = int(data.get('device', 0))
|
||||||
scanner_config['gain'] = int(data.get('gain', 40))
|
scanner_config['gain'] = int(data.get('gain', 40))
|
||||||
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
||||||
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
|
scanner_config['scan_method'] = str(data.get('scan_method', '')).lower().strip()
|
||||||
|
if data.get('snr_threshold') is not None:
|
||||||
|
scanner_config['snr_threshold'] = float(data.get('snr_threshold'))
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -597,25 +897,68 @@ def start_scanner() -> Response:
|
|||||||
'message': 'start_freq must be less than end_freq'
|
'message': 'start_freq must be less than end_freq'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Check tools based on SDR type
|
# Decide scan method
|
||||||
sdr_type = scanner_config['sdr_type']
|
if not scanner_config['scan_method']:
|
||||||
if sdr_type == 'rtlsdr':
|
scanner_config['scan_method'] = 'power' if find_rtl_power() else 'classic'
|
||||||
if not find_rtl_fm():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
|
||||||
}), 503
|
|
||||||
else:
|
|
||||||
if not find_rx_fm():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
|
||||||
}), 503
|
|
||||||
|
|
||||||
# Start scanner thread
|
sdr_type = scanner_config['sdr_type']
|
||||||
scanner_running = True
|
|
||||||
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
# Power scan only supports RTL-SDR for now
|
||||||
scanner_thread.start()
|
if scanner_config['scan_method'] == 'power':
|
||||||
|
if sdr_type != 'rtlsdr' or not find_rtl_power():
|
||||||
|
scanner_config['scan_method'] = 'classic'
|
||||||
|
|
||||||
|
# Check tools based on chosen method
|
||||||
|
if scanner_config['scan_method'] == 'power':
|
||||||
|
if not find_rtl_power():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
||||||
|
}), 503
|
||||||
|
# Release listening device if active
|
||||||
|
if listening_active_device is not None:
|
||||||
|
app_module.release_sdr_device(listening_active_device)
|
||||||
|
listening_active_device = None
|
||||||
|
# Claim device for scanner
|
||||||
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
scanner_active_device = scanner_config['device']
|
||||||
|
scanner_running = True
|
||||||
|
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
||||||
|
scanner_thread.start()
|
||||||
|
else:
|
||||||
|
if sdr_type == 'rtlsdr':
|
||||||
|
if not find_rtl_fm():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
||||||
|
}), 503
|
||||||
|
else:
|
||||||
|
if not find_rx_fm():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||||
|
}), 503
|
||||||
|
if listening_active_device is not None:
|
||||||
|
app_module.release_sdr_device(listening_active_device)
|
||||||
|
listening_active_device = None
|
||||||
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
scanner_active_device = scanner_config['device']
|
||||||
|
|
||||||
|
scanner_running = True
|
||||||
|
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
||||||
|
scanner_thread.start()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
@@ -626,10 +969,23 @@ def start_scanner() -> Response:
|
|||||||
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
||||||
def stop_scanner() -> Response:
|
def stop_scanner() -> Response:
|
||||||
"""Stop the frequency scanner."""
|
"""Stop the frequency scanner."""
|
||||||
global scanner_running
|
global scanner_running, scanner_active_device, scanner_power_process
|
||||||
|
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
_stop_audio_stream()
|
_stop_audio_stream()
|
||||||
|
if scanner_power_process and scanner_power_process.poll() is None:
|
||||||
|
try:
|
||||||
|
scanner_power_process.terminate()
|
||||||
|
scanner_power_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
scanner_power_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
scanner_power_process = None
|
||||||
|
if scanner_active_device is not None:
|
||||||
|
app_module.release_sdr_device(scanner_active_device)
|
||||||
|
scanner_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -790,11 +1146,33 @@ def get_presets() -> Response:
|
|||||||
@listening_post_bp.route('/audio/start', methods=['POST'])
|
@listening_post_bp.route('/audio/start', methods=['POST'])
|
||||||
def start_audio() -> Response:
|
def start_audio() -> Response:
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
global scanner_running
|
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
||||||
|
|
||||||
# Stop scanner if running
|
# Stop scanner if running
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
|
if scanner_active_device is not None:
|
||||||
|
app_module.release_sdr_device(scanner_active_device)
|
||||||
|
scanner_active_device = None
|
||||||
|
if scanner_thread and scanner_thread.is_alive():
|
||||||
|
try:
|
||||||
|
scanner_thread.join(timeout=2.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if scanner_power_process and scanner_power_process.poll() is None:
|
||||||
|
try:
|
||||||
|
scanner_power_process.terminate()
|
||||||
|
scanner_power_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
scanner_power_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
scanner_power_process = None
|
||||||
|
try:
|
||||||
|
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -838,6 +1216,19 @@ def start_audio() -> Response:
|
|||||||
scanner_config['device'] = device
|
scanner_config['device'] = device
|
||||||
scanner_config['sdr_type'] = sdr_type
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
|
||||||
|
# Claim device for listening audio
|
||||||
|
if listening_active_device is None or listening_active_device != device:
|
||||||
|
if listening_active_device is not None:
|
||||||
|
app_module.release_sdr_device(listening_active_device)
|
||||||
|
error = app_module.claim_sdr_device(device, 'listening')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
listening_active_device = device
|
||||||
|
|
||||||
_start_audio_stream(frequency, modulation)
|
_start_audio_stream(frequency, modulation)
|
||||||
|
|
||||||
if audio_running:
|
if audio_running:
|
||||||
@@ -856,7 +1247,11 @@ def start_audio() -> Response:
|
|||||||
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
||||||
def stop_audio() -> Response:
|
def stop_audio() -> Response:
|
||||||
"""Stop audio."""
|
"""Stop audio."""
|
||||||
|
global listening_active_device
|
||||||
_stop_audio_stream()
|
_stop_audio_stream()
|
||||||
|
if listening_active_device is not None:
|
||||||
|
app_module.release_sdr_device(listening_active_device)
|
||||||
|
listening_active_device = None
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -870,9 +1265,71 @@ def audio_status() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/audio/debug')
|
||||||
|
def audio_debug() -> Response:
|
||||||
|
"""Get audio debug status and recent stderr logs."""
|
||||||
|
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
||||||
|
ffmpeg_log_path = '/tmp/ffmpeg_stderr.log'
|
||||||
|
sample_path = '/tmp/audio_probe.bin'
|
||||||
|
|
||||||
|
def _read_log(path: str) -> str:
|
||||||
|
try:
|
||||||
|
with open(path, 'r') as handle:
|
||||||
|
return handle.read().strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': audio_running,
|
||||||
|
'frequency': audio_frequency,
|
||||||
|
'modulation': audio_modulation,
|
||||||
|
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
||||||
|
'device': scanner_config.get('device', 0),
|
||||||
|
'gain': scanner_config.get('gain', 0),
|
||||||
|
'squelch': scanner_config.get('squelch', 0),
|
||||||
|
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
||||||
|
'rtl_fm_stderr': _read_log(rtl_log_path),
|
||||||
|
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
||||||
|
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/audio/probe')
|
||||||
|
def audio_probe() -> Response:
|
||||||
|
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
||||||
|
global audio_process
|
||||||
|
|
||||||
|
if not audio_process or not audio_process.stdout:
|
||||||
|
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
||||||
|
|
||||||
|
sample_path = '/tmp/audio_probe.bin'
|
||||||
|
size = 0
|
||||||
|
try:
|
||||||
|
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
|
||||||
|
if not ready:
|
||||||
|
return jsonify({'status': 'error', 'message': 'no data available'}), 504
|
||||||
|
data = audio_process.stdout.read(4096)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'no data read'}), 504
|
||||||
|
with open(sample_path, 'wb') as handle:
|
||||||
|
handle.write(data)
|
||||||
|
size = len(data)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', 'bytes': size})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/stream')
|
@listening_post_bp.route('/audio/stream')
|
||||||
def stream_audio() -> Response:
|
def stream_audio() -> Response:
|
||||||
"""Stream MP3 audio."""
|
"""Stream WAV audio."""
|
||||||
|
# Optionally restart pipeline so the stream starts with a fresh header
|
||||||
|
if request.args.get('fresh') == '1' and audio_running:
|
||||||
|
try:
|
||||||
|
_start_audio_stream(audio_frequency or 0.0, audio_modulation or 'fm')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Audio stream restart failed: {e}")
|
||||||
|
|
||||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
if audio_running and audio_process:
|
if audio_running and audio_process:
|
||||||
@@ -888,6 +1345,8 @@ def stream_audio() -> Response:
|
|||||||
if not proc or not proc.stdout:
|
if not proc or not proc.stdout:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
# First byte timeout to avoid hanging clients forever
|
||||||
|
first_chunk_deadline = time.time() + 3.0
|
||||||
while audio_running and proc.poll() is None:
|
while audio_running and proc.poll() is None:
|
||||||
# Use select to avoid blocking forever
|
# Use select to avoid blocking forever
|
||||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||||
@@ -898,6 +1357,10 @@ def stream_audio() -> Response:
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
# If no data arrives shortly after start, exit so caller can retry
|
||||||
|
if time.time() > first_chunk_deadline:
|
||||||
|
logger.warning("Audio stream timed out waiting for first chunk")
|
||||||
|
break
|
||||||
# Timeout - check if process died
|
# Timeout - check if process died
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
break
|
break
|
||||||
@@ -908,9 +1371,9 @@ def stream_audio() -> Response:
|
|||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
generate(),
|
generate(),
|
||||||
mimetype='audio/mpeg',
|
mimetype='audio/wav',
|
||||||
headers={
|
headers={
|
||||||
'Content-Type': 'audio/mpeg',
|
'Content-Type': 'audio/wav',
|
||||||
'Cache-Control': 'no-cache, no-store',
|
'Cache-Control': 'no-cache, no-store',
|
||||||
'X-Accel-Buffering': 'no',
|
'X-Accel-Buffering': 'no',
|
||||||
'Transfer-Encoding': 'chunked',
|
'Transfer-Encoding': 'chunked',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-dark: #0a0c10;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0f1218;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #151a23;
|
--bg-card: #151a23;
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -94,7 +96,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -132,7 +134,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,7 +626,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-value {
|
.telemetry-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -680,7 +682,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-icao {
|
.aircraft-icao {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
@@ -700,7 +702,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-detail-value {
|
.aircraft-detail-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -790,7 +792,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,7 +803,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,7 +881,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -911,7 +913,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1025,7 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -1057,7 +1059,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.airband-status {
|
.airband-status {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1407,7 +1409,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.strip-value {
|
.strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1545,7 +1547,7 @@ body {
|
|||||||
|
|
||||||
.report-grid span:nth-child(even) {
|
.report-grid span:nth-child(even) {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-highlights {
|
.report-highlights {
|
||||||
@@ -1784,7 +1786,7 @@ body {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1938,7 +1940,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.squawk-code {
|
.squawk-code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-dark: #0a0c10;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0f1218;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #141a24;
|
--bg-card: #141a24;
|
||||||
@@ -20,14 +22,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-bg {
|
.radar-bg {
|
||||||
@@ -91,7 +93,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +270,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -306,7 +308,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-meta {
|
.panel-meta {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -347,7 +349,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-row td,
|
.empty-row td,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
/* CSS Variables (inherited from main theme) */
|
/* CSS Variables (inherited from main theme) */
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-primary: #0a0a0f;
|
--bg-primary: #0a0a0f;
|
||||||
--bg-secondary: #12121a;
|
--bg-secondary: #12121a;
|
||||||
--text-primary: #e0e0e0;
|
--text-primary: #e0e0e0;
|
||||||
@@ -55,7 +57,7 @@
|
|||||||
.agent-indicator-label {
|
.agent-indicator-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-indicator-count {
|
.agent-indicator-count {
|
||||||
@@ -178,7 +180,7 @@
|
|||||||
.agent-selector-item-url {
|
.agent-selector-item-url {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -203,7 +205,7 @@
|
|||||||
background: rgba(0, 212, 255, 0.1);
|
background: rgba(0, 212, 255, 0.1);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-badge.local,
|
.agent-badge.local,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-dark: #0a0c10;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0f1218;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #151a23;
|
--bg-card: #151a23;
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -97,7 +99,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -134,7 +136,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +185,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.strip-value {
|
.strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -287,7 +289,7 @@ body {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -367,7 +369,7 @@ body {
|
|||||||
/* Leaflet overrides - Dark map styling */
|
/* Leaflet overrides - Dark map styling */
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
background: var(--bg-dark) !important;
|
background: var(--bg-dark) !important;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Using actual dark tiles now - no filter needed */
|
/* Using actual dark tiles now - no filter needed */
|
||||||
@@ -518,7 +520,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-mmsi {
|
.vessel-mmsi {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
@@ -548,7 +550,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -611,13 +613,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-type {
|
.vessel-item-type {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-speed {
|
.vessel-item-speed {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -687,7 +689,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +700,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,7 +719,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1004,7 +1006,7 @@ body {
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
|
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,7 +1081,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-category {
|
.dsc-message-category {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1096,13 +1098,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-time {
|
.dsc-message-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-mmsi {
|
.dsc-message-mmsi {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-orange);
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
@@ -1120,7 +1122,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-pos {
|
.dsc-message-pos {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -1157,7 +1159,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-mmsi {
|
.dsc-distress-alert .dsc-alert-mmsi {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -1177,7 +1179,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-position {
|
.dsc-distress-alert .dsc-alert-position {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -1188,7 +1190,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
background: var(--timeline-bg);
|
background: var(--timeline-bg);
|
||||||
border: 1px solid var(--timeline-border);
|
border: 1px solid var(--timeline-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', 'Consolas', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +497,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-timeline-tooltip-header {
|
.activity-timeline-tooltip-header {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-name {
|
.device-name {
|
||||||
font-family: 'Inter', -apple-system, sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
PROTOCOL BADGES
|
PROTOCOL BADGES
|
||||||
============================================ */
|
============================================ */
|
||||||
.signal-proto-badge.device-protocol {
|
.signal-proto-badge.device-protocol {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
.device-heuristic-badge {
|
.device-heuristic-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rssi-current {
|
.rssi-current {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -186,13 +186,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rssi-value {
|
.rssi-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rssi-current-value {
|
.rssi-current-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-range-band .range-label {
|
.device-range-band .range-label {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--range-color);
|
color: var(--range-color);
|
||||||
@@ -230,13 +230,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-range-band .range-estimate {
|
.device-range-band .range-estimate {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-range-band .range-confidence {
|
.device-range-band .range-confidence {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-manufacturer .mfr-name {
|
.device-manufacturer .mfr-name {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -280,7 +280,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-seen-count .seen-icon {
|
.device-seen-count .seen-icon {
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-timestamp {
|
.device-timestamp {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-uuid {
|
.device-uuid {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: var(--bg-tertiary, #1a1a1a);
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heuristic-item .heuristic-status {
|
.heuristic-item .heuristic-status {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -426,7 +426,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-card-title {
|
.message-card-title {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-card-details {
|
.message-card-details {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -476,7 +476,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-action-btn {
|
.message-action-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -623,7 +623,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-modal-subtitle {
|
.signal-details-modal-subtitle {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
}
|
}
|
||||||
@@ -635,7 +635,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-copy-addr-btn {
|
.signal-details-copy-addr-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--bg-secondary, #252525);
|
background: var(--bg-secondary, #252525);
|
||||||
@@ -677,7 +677,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-section-title {
|
.modal-section-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -698,7 +698,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-rssi-large {
|
.modal-rssi-large {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
@@ -744,7 +744,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-signal-stats .stat-value {
|
.modal-signal-stats .stat-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -778,7 +778,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-info-grid .info-value.mono {
|
.modal-info-grid .info-value.mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +790,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-uuid {
|
.modal-uuid {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--bg-secondary, #1a1a1a);
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
@@ -822,7 +822,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heuristic-indicator {
|
.heuristic-indicator {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
|
|||||||
@@ -0,0 +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: 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); }
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-feed-title {
|
.signal-feed-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
.signal-filter-btn[data-filter="baseline"] .filter-dot { background: var(--signal-baseline); }
|
.signal-filter-btn[data-filter="baseline"] .filter-dot { background: var(--signal-baseline); }
|
||||||
|
|
||||||
.signal-filter-count {
|
.signal-filter-count {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
|
|
||||||
/* Protocol badge */
|
/* Protocol badge */
|
||||||
.signal-proto-badge {
|
.signal-proto-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
|
|
||||||
/* Frequency/Address badge */
|
/* Frequency/Address badge */
|
||||||
.signal-freq-badge {
|
.signal-freq-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -376,7 +376,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-sender {
|
.signal-sender {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
@@ -392,7 +392,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-timestamp {
|
.signal-timestamp {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -400,7 +400,7 @@
|
|||||||
|
|
||||||
/* Message content preview */
|
/* Message content preview */
|
||||||
.signal-message {
|
.signal-message {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -638,13 +638,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-advanced-value {
|
.signal-advanced-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-raw-data {
|
.signal-raw-data {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -716,7 +716,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@@ -864,7 +864,7 @@
|
|||||||
.signal-search-input {
|
.signal-search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@@ -887,7 +887,7 @@
|
|||||||
SEEN COUNT BADGE
|
SEEN COUNT BADGE
|
||||||
============================================ */
|
============================================ */
|
||||||
.signal-seen-count {
|
.signal-seen-count {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -923,7 +923,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-sensor-reading .sensor-value {
|
.signal-sensor-reading .sensor-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -967,7 +967,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-meter-reading .meter-value {
|
.signal-meter-reading .meter-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1032,7 +1032,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meter-aggregated-value {
|
.meter-aggregated-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1049,7 +1049,7 @@
|
|||||||
|
|
||||||
/* Delta badge */
|
/* Delta badge */
|
||||||
.meter-delta {
|
.meter-delta {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -1078,14 +1078,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meter-sparkline-placeholder {
|
.meter-sparkline-placeholder {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rate display */
|
/* Rate display */
|
||||||
.meter-rate-value {
|
.meter-rate-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent-cyan, #4a9eff);
|
color: var(--accent-cyan, #4a9eff);
|
||||||
@@ -1161,7 +1161,7 @@
|
|||||||
DISTANCE DISPLAY
|
DISTANCE DISPLAY
|
||||||
============================================ */
|
============================================ */
|
||||||
.signal-distance {
|
.signal-distance {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -1308,7 +1308,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-strength-indicator.no-data {
|
.signal-strength-indicator.no-data {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -1324,7 +1324,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-strength-label {
|
.signal-strength-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1404,7 +1404,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1520,7 +1520,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.station-raw-modal-title {
|
.station-raw-modal-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
@@ -1556,7 +1556,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.station-raw-data-display {
|
.station-raw-data-display {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--accent-green, #00ff88);
|
color: var(--accent-green, #00ff88);
|
||||||
@@ -1581,7 +1581,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.station-raw-copy-btn {
|
.station-raw-copy-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--accent-purple, #8a2be2);
|
background: var(--accent-purple, #8a2be2);
|
||||||
@@ -1751,7 +1751,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-modal-title {
|
.signal-details-modal-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
@@ -1788,7 +1788,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-copy-btn {
|
.signal-details-copy-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--accent-purple, #8a2be2);
|
background: var(--accent-purple, #8a2be2);
|
||||||
@@ -1814,7 +1814,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-title {
|
.signal-details-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1826,7 +1826,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-message {
|
.signal-details-message {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
background: var(--bg-secondary, #252525);
|
background: var(--bg-secondary, #252525);
|
||||||
@@ -1857,14 +1857,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-value {
|
.signal-details-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Raw data in modal */
|
/* Raw data in modal */
|
||||||
.signal-details-modal .signal-raw-data {
|
.signal-details-modal .signal-raw-data {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary, #aaa);
|
color: var(--text-secondary, #aaa);
|
||||||
background: var(--bg-tertiary, #1a1a1a);
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
background: var(--bg-card, #1a1a1a);
|
background: var(--bg-card, #1a1a1a);
|
||||||
border: 1px solid var(--border-color, #333);
|
border: 1px solid var(--border-color, #333);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-timeline.collapsed .signal-timeline-body {
|
.signal-timeline.collapsed .signal-timeline-body {
|
||||||
|
|||||||
@@ -259,7 +259,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.update-version-value {
|
.update-version-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary, #9ca3af);
|
color: var(--text-secondary, #9ca3af);
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.update-release-notes code {
|
.update-release-notes code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--bg-tertiary, #151a23);
|
background: var(--bg-tertiary, #151a23);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.update-result-text code {
|
.update-result-text code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT Base Styles
|
||||||
|
* Reset, typography, and foundational element styles
|
||||||
|
* Requires: variables.css to be imported first
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CSS RESET
|
||||||
|
============================================ */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
============================================ */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: var(--text-4xl); }
|
||||||
|
h2 { font-size: var(--text-3xl); }
|
||||||
|
h3 { font-size: var(--text-2xl); }
|
||||||
|
h4 { font-size: var(--text-xl); }
|
||||||
|
h5 { font-size: var(--text-lg); }
|
||||||
|
h6 { font-size: var(--text-base); }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-cyan-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong, b {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd, pre, samp {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FORM ELEMENTS
|
||||||
|
============================================ */
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-cyan-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
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='%239ca3af' 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: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TABLES
|
||||||
|
============================================ */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LISTS
|
||||||
|
============================================ */
|
||||||
|
ul, ol {
|
||||||
|
padding-left: var(--space-6);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UTILITY CLASSES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
.text-primary { color: var(--text-primary); }
|
||||||
|
.text-secondary { color: var(--text-secondary); }
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-cyan { color: var(--accent-cyan); }
|
||||||
|
.text-green { color: var(--accent-green); }
|
||||||
|
.text-red { color: var(--accent-red); }
|
||||||
|
.text-orange { color: var(--accent-orange); }
|
||||||
|
.text-amber { color: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Font utilities */
|
||||||
|
.font-mono { font-family: var(--font-mono); }
|
||||||
|
.font-medium { font-weight: var(--font-medium); }
|
||||||
|
.font-semibold { font-weight: var(--font-semibold); }
|
||||||
|
.font-bold { font-weight: var(--font-bold); }
|
||||||
|
|
||||||
|
/* Text sizes */
|
||||||
|
.text-xs { font-size: var(--text-xs); }
|
||||||
|
.text-sm { font-size: var(--text-sm); }
|
||||||
|
.text-base { font-size: var(--text-base); }
|
||||||
|
.text-lg { font-size: var(--text-lg); }
|
||||||
|
.text-xl { font-size: var(--text-xl); }
|
||||||
|
|
||||||
|
/* Display */
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.block { display: block; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.inline-flex { display: inline-flex; }
|
||||||
|
.grid { display: grid; }
|
||||||
|
|
||||||
|
/* Flexbox */
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.gap-1 { gap: var(--space-1); }
|
||||||
|
.gap-2 { gap: var(--space-2); }
|
||||||
|
.gap-3 { gap: var(--space-3); }
|
||||||
|
.gap-4 { gap: var(--space-4); }
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
.m-0 { margin: 0; }
|
||||||
|
.mt-2 { margin-top: var(--space-2); }
|
||||||
|
.mt-4 { margin-top: var(--space-4); }
|
||||||
|
.mb-2 { margin-bottom: var(--space-2); }
|
||||||
|
.mb-4 { margin-bottom: var(--space-4); }
|
||||||
|
.p-2 { padding: var(--space-2); }
|
||||||
|
.p-3 { padding: var(--space-3); }
|
||||||
|
.p-4 { padding: var(--space-4); }
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
.rounded { border-radius: var(--radius-md); }
|
||||||
|
.rounded-lg { border-radius: var(--radius-lg); }
|
||||||
|
.border { border: 1px solid var(--border-color); }
|
||||||
|
|
||||||
|
/* Truncate text */
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen reader only */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SCROLLBAR STYLING
|
||||||
|
============================================ */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-light);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-light) var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SELECTION
|
||||||
|
============================================ */
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UX POLISH - TRANSITIONS & INTERACTIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Smooth page transitions */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better focus ring for all interactive elements */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-cyan);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove focus ring for mouse users */
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state feedback */
|
||||||
|
button:active:not(:disabled),
|
||||||
|
a:active,
|
||||||
|
[role="button"]:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for all interactive elements */
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
[role="button"] {
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
transform var(--transition-fast),
|
||||||
|
opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle hover lift effect for cards and panels */
|
||||||
|
.card:hover,
|
||||||
|
.panel:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link underline on hover */
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip link for accessibility */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 0;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
z-index: 9999;
|
||||||
|
transition: top var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High contrast mode support */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
:root {
|
||||||
|
--border-color: #4b5563;
|
||||||
|
--text-secondary: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT UI Components
|
||||||
|
* Reusable component styles for buttons, cards, badges, etc.
|
||||||
|
* Requires: variables.css and base.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BUTTONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Base button */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variants */
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-cyan-hover);
|
||||||
|
border-color: var(--accent-cyan-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--accent-red);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: var(--accent-green);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover:not(:disabled) {
|
||||||
|
background: #16a34a;
|
||||||
|
border-color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button sizes */
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon button */
|
||||||
|
.btn-icon {
|
||||||
|
padding: var(--space-2);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.btn-sm {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CARDS / PANELS
|
||||||
|
============================================ */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel variant (used in dashboards) */
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-indicator.active {
|
||||||
|
background: var(--status-online);
|
||||||
|
box-shadow: 0 0 8px var(--status-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BADGES
|
||||||
|
============================================ */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: var(--accent-green-dim);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: var(--accent-orange-dim);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background: var(--accent-red-dim);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATUS INDICATORS
|
||||||
|
============================================ */
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--status-offline);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online,
|
||||||
|
.status-dot.active {
|
||||||
|
background: var(--status-online);
|
||||||
|
box-shadow: 0 0 6px var(--status-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.warning {
|
||||||
|
background: var(--status-warning);
|
||||||
|
box-shadow: 0 0 6px var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.error,
|
||||||
|
.status-dot.offline {
|
||||||
|
background: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.inactive {
|
||||||
|
background: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for active status */
|
||||||
|
.status-dot.pulse {
|
||||||
|
animation: statusPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes statusPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
EMPTY STATE
|
||||||
|
============================================ */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-description {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-action {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LOADING STATES
|
||||||
|
============================================ */
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-cyan);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-lg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loader */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--bg-tertiary) 25%,
|
||||||
|
var(--bg-elevated) 50%,
|
||||||
|
var(--bg-tertiary) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATS STRIP
|
||||||
|
============================================ */
|
||||||
|
.stats-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
height: var(--stats-strip-height);
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FORM GROUPS
|
||||||
|
============================================ */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline checkbox/radio */
|
||||||
|
.form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ALERTS / TOASTS
|
||||||
|
============================================ */
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: var(--accent-green-dim);
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background: var(--accent-orange-dim);
|
||||||
|
border-color: var(--accent-orange);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: var(--accent-red-dim);
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOOLTIPS
|
||||||
|
============================================ */
|
||||||
|
[data-tooltip] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity var(--transition-fast), visibility var(--transition-fast);
|
||||||
|
z-index: var(--z-tooltip);
|
||||||
|
pointer-events: none;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ICONS
|
||||||
|
============================================ */
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--sm {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--lg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SECTION HEADERS
|
||||||
|
============================================ */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DIVIDERS
|
||||||
|
============================================ */
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-vertical {
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UX POLISH - ENHANCED INTERACTIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Button hover lift */
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card/Panel hover effects */
|
||||||
|
.card,
|
||||||
|
.panel {
|
||||||
|
transition:
|
||||||
|
box-shadow var(--transition-base),
|
||||||
|
border-color var(--transition-base),
|
||||||
|
transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.panel:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats strip value highlight on hover */
|
||||||
|
.strip-stat {
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-stat:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dot pulse animation */
|
||||||
|
.status-dot.online,
|
||||||
|
.status-dot.active {
|
||||||
|
animation: statusGlow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes statusGlow {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 6px var(--status-online);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 12px var(--status-online), 0 0 20px var(--status-online);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge hover effect */
|
||||||
|
.badge {
|
||||||
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert entrance animation */
|
||||||
|
.alert {
|
||||||
|
animation: alertSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes alertSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner smooth appearance */
|
||||||
|
.spinner {
|
||||||
|
animation: spin 0.8s linear infinite, fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input focus glow */
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-cyan-dim), 0 0 20px rgba(74, 158, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav item active indicator */
|
||||||
|
.mode-nav-btn.active::after,
|
||||||
|
.mobile-nav-btn.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 60%;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth tooltip appearance */
|
||||||
|
[data-tooltip]::after {
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-fast),
|
||||||
|
visibility var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
transform: translateX(-50%) translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover::after {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state with better visual feedback */
|
||||||
|
:disabled,
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(30%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,950 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT Layout Styles
|
||||||
|
* Global layout structure: header, navigation, sidebar, main content
|
||||||
|
* Requires: variables.css, base.css, components.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
APP SHELL
|
||||||
|
============================================ */
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GLOBAL HEADER
|
||||||
|
============================================ */
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo */
|
||||||
|
.app-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-tagline {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page title in header */
|
||||||
|
.app-header-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-subtitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header utilities */
|
||||||
|
.header-utilities {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-clock-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GLOBAL NAVIGATION
|
||||||
|
============================================ */
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
height: var(--nav-height);
|
||||||
|
gap: var(--space-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav groups */
|
||||||
|
.nav-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown trigger */
|
||||||
|
.nav-dropdown-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-arrow {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group.open .nav-dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown menu */
|
||||||
|
.nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: var(--space-1);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group.open .nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav items */
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav divider */
|
||||||
|
.nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav utilities (right side) */
|
||||||
|
.nav-utilities {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MOBILE NAVIGATION
|
||||||
|
============================================ */
|
||||||
|
.mobile-nav {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-btn:hover,
|
||||||
|
.mobile-nav-btn.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger button */
|
||||||
|
.hamburger-btn {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--text-secondary);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.open span:nth-child(1) {
|
||||||
|
transform: rotate(45deg) translate(4px, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.open span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.open span:nth-child(3) {
|
||||||
|
transform: rotate(-45deg) translate(4px, -4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CONTENT LAYOUTS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Main content with optional sidebar */
|
||||||
|
.content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.app-sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content area */
|
||||||
|
.app-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content-full {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DASHBOARD LAYOUTS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Full-screen dashboard (maps, etc.) */
|
||||||
|
.dashboard-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header-logo {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header-logo span {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-normal);
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PAGE LAYOUTS
|
||||||
|
============================================ */
|
||||||
|
.page-container {
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-description {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE BREAKPOINTS
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.app-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-fixed);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-utilities {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-fixed);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
OVERLAY (for mobile drawers)
|
||||||
|
============================================ */
|
||||||
|
.drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: calc(var(--z-fixed) - 1);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BACK LINK
|
||||||
|
============================================ */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODE NAVIGATION (from index.css)
|
||||||
|
Used by nav.html partial across all pages
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Mode Navigation Bar */
|
||||||
|
.mode-nav {
|
||||||
|
display: none;
|
||||||
|
background: #151a23 !important; /* Explicit color - forced to ensure consistency */
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mode-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active .nav-icon {
|
||||||
|
filter: brightness(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn .nav-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn:hover {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown Navigation */
|
||||||
|
.mode-nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||||
|
font-size: 8px;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||||
|
filter: brightness(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav Bar Utilities (clock, theme, tools) */
|
||||||
|
.nav-utilities {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.nav-utilities {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-time {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme toggle icon states in nav bar */
|
||||||
|
.nav-tool-btn .icon-sun,
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-sun {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-sun {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-moon {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effects toggle icon states */
|
||||||
|
.nav-tool-btn .icon-effects-off {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-on {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT Design Tokens
|
||||||
|
* Single source of truth for colors, spacing, typography, and effects
|
||||||
|
* Import this file FIRST in any stylesheet that needs design tokens
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ============================================
|
||||||
|
COLOR PALETTE - Dark Theme (Default)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Backgrounds - layered depth system */
|
||||||
|
--bg-primary: #0a0c10;
|
||||||
|
--bg-secondary: #0f1218;
|
||||||
|
--bg-tertiary: #151a23;
|
||||||
|
--bg-card: #121620;
|
||||||
|
--bg-elevated: #1a202c;
|
||||||
|
--bg-overlay: rgba(0, 0, 0, 0.7);
|
||||||
|
|
||||||
|
/* Background aliases for components */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
|
||||||
|
/* Accent colors */
|
||||||
|
--accent-cyan: #4a9eff;
|
||||||
|
--accent-cyan-dim: rgba(74, 158, 255, 0.15);
|
||||||
|
--accent-cyan-hover: #6bb3ff;
|
||||||
|
--accent-green: #22c55e;
|
||||||
|
--accent-green-dim: rgba(34, 197, 94, 0.15);
|
||||||
|
--accent-red: #ef4444;
|
||||||
|
--accent-red-dim: rgba(239, 68, 68, 0.15);
|
||||||
|
--accent-orange: #f59e0b;
|
||||||
|
--accent-orange-dim: rgba(245, 158, 11, 0.15);
|
||||||
|
--accent-amber: #d4a853;
|
||||||
|
--accent-amber-dim: rgba(212, 168, 83, 0.15);
|
||||||
|
--accent-yellow: #eab308;
|
||||||
|
--accent-purple: #a855f7;
|
||||||
|
|
||||||
|
/* Text hierarchy */
|
||||||
|
--text-primary: #e8eaed;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--text-dim: #4b5563;
|
||||||
|
--text-muted: #374151;
|
||||||
|
--text-inverse: #0a0c10;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-color: #1f2937;
|
||||||
|
--border-light: #374151;
|
||||||
|
--border-glow: rgba(74, 158, 255, 0.2);
|
||||||
|
--border-focus: var(--accent-cyan);
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
--status-online: #22c55e;
|
||||||
|
--status-warning: #f59e0b;
|
||||||
|
--status-error: #ef4444;
|
||||||
|
--status-offline: #6b7280;
|
||||||
|
--status-info: #3b82f6;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SPACING SCALE
|
||||||
|
============================================ */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
============================================ */
|
||||||
|
--font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
|
||||||
|
/* Font sizes */
|
||||||
|
--text-xs: 10px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-lg: 16px;
|
||||||
|
--text-xl: 18px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
--text-4xl: 30px;
|
||||||
|
|
||||||
|
/* Font weights */
|
||||||
|
--font-normal: 400;
|
||||||
|
--font-medium: 500;
|
||||||
|
--font-semibold: 600;
|
||||||
|
--font-bold: 700;
|
||||||
|
|
||||||
|
/* Line heights */
|
||||||
|
--leading-tight: 1.25;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.75;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BORDERS & RADIUS
|
||||||
|
============================================ */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SHADOWS
|
||||||
|
============================================ */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-glow: 0 0 20px rgba(74, 158, 255, 0.15);
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TRANSITIONS
|
||||||
|
============================================ */
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-base: 200ms ease;
|
||||||
|
--transition-slow: 300ms ease;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Z-INDEX SCALE
|
||||||
|
============================================ */
|
||||||
|
--z-base: 0;
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-sticky: 200;
|
||||||
|
--z-fixed: 300;
|
||||||
|
--z-modal-backdrop: 400;
|
||||||
|
--z-modal: 500;
|
||||||
|
--z-toast: 600;
|
||||||
|
--z-tooltip: 700;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LAYOUT
|
||||||
|
============================================ */
|
||||||
|
--header-height: 60px;
|
||||||
|
--nav-height: 44px;
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--stats-strip-height: 36px;
|
||||||
|
--content-max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIGHT THEME OVERRIDES
|
||||||
|
============================================ */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #f8fafc;
|
||||||
|
--bg-secondary: #f1f5f9;
|
||||||
|
--bg-tertiary: #e2e8f0;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-elevated: #f8fafc;
|
||||||
|
--bg-overlay: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
/* Background aliases for components */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
|
||||||
|
--accent-cyan: #2563eb;
|
||||||
|
--accent-cyan-dim: rgba(37, 99, 235, 0.1);
|
||||||
|
--accent-cyan-hover: #1d4ed8;
|
||||||
|
--accent-green: #16a34a;
|
||||||
|
--accent-green-dim: rgba(22, 163, 74, 0.1);
|
||||||
|
--accent-red: #dc2626;
|
||||||
|
--accent-red-dim: rgba(220, 38, 38, 0.1);
|
||||||
|
--accent-orange: #d97706;
|
||||||
|
--accent-orange-dim: rgba(217, 119, 6, 0.1);
|
||||||
|
--accent-amber: #b45309;
|
||||||
|
--accent-amber-dim: rgba(180, 83, 9, 0.1);
|
||||||
|
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--text-muted: #cbd5e1;
|
||||||
|
--text-inverse: #f8fafc;
|
||||||
|
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--border-light: #cbd5e1;
|
||||||
|
--border-glow: rgba(37, 99, 235, 0.15);
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
|
||||||
|
--shadow-glow: 0 0 20px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
REDUCED MOTION
|
||||||
|
============================================ */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--transition-fast: 0ms;
|
||||||
|
--transition-base: 0ms;
|
||||||
|
--transition-slow: 0ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
/* ============================================
|
||||||
|
Global Navigation Styles
|
||||||
|
Shared across all pages using nav.html
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Icon base (kept lightweight for nav usage) */
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode Navigation Bar */
|
||||||
|
.mode-nav {
|
||||||
|
display: none;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
|
||||||
|
border-bottom: 1px solid var(--border-color, #202833);
|
||||||
|
padding: 0 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mode-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color, #202833);
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.8);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active .nav-icon {
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(24, 31, 44, 0.85);
|
||||||
|
border: 1px solid var(--border-light, #2b3645);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.95);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown Navigation */
|
||||||
|
.mode-nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.8);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: rgba(16, 22, 32, 0.98);
|
||||||
|
border: 1px solid var(--border-color, #202833);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||||
|
background: rgba(27, 36, 51, 0.95);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav Bar Utilities */
|
||||||
|
.nav-utilities {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.nav-utilities {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #8a97a8);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-time {
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(20, 33, 53, 0.6);
|
||||||
|
border: 1px solid rgba(77, 125, 191, 0.12);
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn:focus-visible,
|
||||||
|
.mode-nav-dropdown-btn:focus-visible,
|
||||||
|
.nav-action-btn:focus-visible,
|
||||||
|
.nav-tool-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-cyan, #4d7dbf);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
+215
-294
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
.landing-title {
|
.landing-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 2.2rem;
|
font-size: 2.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.4em;
|
letter-spacing: 0.4em;
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.landing-tagline {
|
.landing-tagline {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
/* Hacker Style Error */
|
/* Hacker Style Error */
|
||||||
.flash-error {
|
.flash-error {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box; /* Crucial for visibility */
|
box-sizing: border-box; /* Crucial for visibility */
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
border: 2px solid var(--accent-cyan);
|
border: 2px solid var(--accent-cyan);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
|
|
||||||
.landing-version {
|
.landing-version {
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
border-color: rgba(74, 158, 255, 0.3);
|
border-color: rgba(74, 158, 255, 0.3);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-value {
|
.aprs-strip .strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
|
|
||||||
/* Time display */
|
/* Time display */
|
||||||
.aprs-strip .strip-time {
|
.aprs-strip .strip-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-sidebar-toggle-text {
|
.mesh-sidebar-toggle-text {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -242,14 +242,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-status-text {
|
.mesh-strip-status-text {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-select {
|
.mesh-strip-select {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -260,7 +260,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-input {
|
.mesh-strip-input {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-btn {
|
.mesh-strip-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -325,7 +325,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-value {
|
.mesh-strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-label {
|
.mesh-strip-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -408,7 +408,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
.mesh-map-stats {
|
.mesh-map-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -459,7 +459,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-map .leaflet-popup-content {
|
.mesh-map .leaflet-popup-content {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin: 10px 12px;
|
margin: 10px 12px;
|
||||||
}
|
}
|
||||||
@@ -479,7 +479,7 @@
|
|||||||
0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */
|
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 */
|
inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */
|
||||||
color: #000;
|
color: #000;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
|
text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
|
||||||
@@ -573,7 +573,7 @@
|
|||||||
|
|
||||||
.mesh-node-value {
|
.mesh-node-value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-node-id {
|
.mesh-node-id {
|
||||||
@@ -612,7 +612,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-channel-index {
|
.mesh-channel-index {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -623,7 +623,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-channel-name {
|
.mesh-channel-name {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -640,7 +640,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-channel-badge {
|
.mesh-channel-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -715,7 +715,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-messages-title {
|
.mesh-messages-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -735,7 +735,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-messages-filter select {
|
.mesh-messages-filter select {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -800,7 +800,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,7 +829,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-message-channel {
|
.mesh-message-channel {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -838,7 +838,7 @@
|
|||||||
|
|
||||||
.mesh-message-time {
|
.mesh-message-time {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-message-body {
|
.mesh-message-body {
|
||||||
@@ -849,7 +849,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-message-body.app-type {
|
.mesh-message-body.app-type {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -870,7 +870,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,7 +921,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-message-status {
|
.mesh-message-status {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -986,7 +986,7 @@
|
|||||||
|
|
||||||
#meshChannelModal input[type="text"],
|
#meshChannelModal input[type="text"],
|
||||||
#meshChannelModal select {
|
#meshChannelModal select {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -1020,7 +1020,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-compose-channel {
|
.mesh-compose-channel {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -1037,7 +1037,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-compose-to {
|
.mesh-compose-to {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -1064,7 +1064,7 @@
|
|||||||
|
|
||||||
.mesh-compose-input {
|
.mesh-compose-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -1111,7 +1111,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-compose-hint {
|
.mesh-compose-hint {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
@@ -1232,7 +1232,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #000;
|
color: #000;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1289,7 +1289,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-traceroute-label {
|
.mesh-traceroute-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -1321,7 +1321,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-traceroute-hop-node {
|
.mesh-traceroute-hop-node {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1329,14 +1329,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-traceroute-hop-id {
|
.mesh-traceroute-hop-id {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-traceroute-snr {
|
.mesh-traceroute-snr {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -1371,7 +1371,7 @@
|
|||||||
|
|
||||||
.mesh-traceroute-timestamp {
|
.mesh-traceroute-timestamp {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -1402,7 +1402,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1426,7 +1426,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1459,7 +1459,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -1494,7 +1494,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-chart-label {
|
.mesh-chart-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
fill: var(--text-dim);
|
fill: var(--text-dim);
|
||||||
}
|
}
|
||||||
@@ -1525,7 +1525,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-network-node-id {
|
.mesh-network-node-id {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1553,13 +1553,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-network-neighbor-id {
|
.mesh-network-neighbor-id {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-network-neighbor-snr {
|
.mesh-network-neighbor-snr {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -1592,7 +1592,7 @@
|
|||||||
.mesh-badge {
|
.mesh-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-stations-title {
|
.spy-stations-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-station-name {
|
.spy-station-name {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
|
|
||||||
/* Type Badge */
|
/* Type Badge */
|
||||||
.spy-station-badge {
|
.spy-station-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-meta-mode {
|
.spy-meta-mode {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-orange);
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-freq-list {
|
.spy-freq-list {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-freq-item {
|
.spy-freq-item {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-freq-select {
|
.spy-freq-select {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
+21
-21
@@ -86,14 +86,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-strip-status-text {
|
.sstv-strip-status-text {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-strip-btn {
|
.sstv-strip-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-strip-value {
|
.sstv-strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-strip-label {
|
.sstv-strip-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
.sstv-loc-input {
|
.sstv-loc-input {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-mode-label {
|
.sstv-mode-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -300,7 +300,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-status-message {
|
.sstv-status-message {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -362,14 +362,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-gallery-count {
|
.sstv-gallery-count {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -416,7 +416,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-image-mode {
|
.sstv-image-mode {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -424,7 +424,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-image-timestamp {
|
.sstv-image-timestamp {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
@@ -500,7 +500,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-map-label {
|
.sstv-map-label {
|
||||||
@@ -526,7 +526,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-pass-label {
|
.sstv-pass-label {
|
||||||
@@ -561,7 +561,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-iss-label {
|
.sstv-iss-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #ffcc00;
|
color: #ffcc00;
|
||||||
@@ -607,7 +607,7 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -635,7 +635,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-countdown-value {
|
.sstv-countdown-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -661,7 +661,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-countdown-label {
|
.sstv-countdown-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -686,7 +686,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-detail-label {
|
.sstv-detail-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -694,7 +694,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-detail-value {
|
.sstv-detail-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -708,7 +708,7 @@
|
|||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -167,7 +167,7 @@
|
|||||||
}
|
}
|
||||||
.tscm-privilege-warning .warning-action {
|
.tscm-privilege-warning .warning-action {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
@@ -972,7 +972,7 @@
|
|||||||
.known-device-id {
|
.known-device-id {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.known-device-actions {
|
.known-device-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1211,7 +1211,7 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.cap-can-list, .cap-cannot-list {
|
.cap-can-list, .cap-cannot-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-dark: #0a0c10;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0f1218;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #151a23;
|
--bg-card: #151a23;
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -93,7 +95,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
@@ -142,7 +144,7 @@ body {
|
|||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +166,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +459,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-value {
|
.telemetry-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -543,7 +545,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pass-time {
|
.pass-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom controls bar */
|
/* Bottom controls bar */
|
||||||
@@ -579,7 +581,7 @@ body {
|
|||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -364,7 +364,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.about-version {
|
.about-version {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
(() => {
|
||||||
|
const dropdowns = Array.from(document.querySelectorAll('.mode-nav-dropdown'));
|
||||||
|
if (!dropdowns.length) return;
|
||||||
|
|
||||||
|
const closeAll = () => {
|
||||||
|
dropdowns.forEach((dropdown) => dropdown.classList.remove('open'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDropdown = (dropdown) => {
|
||||||
|
if (!dropdown.classList.contains('open')) {
|
||||||
|
closeAll();
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const menuLink = event.target.closest('.mode-nav-dropdown-menu a');
|
||||||
|
if (menuLink) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
window.location.href = menuLink.href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = event.target.closest('.mode-nav-dropdown-btn');
|
||||||
|
if (button) {
|
||||||
|
event.preventDefault();
|
||||||
|
const dropdown = button.closest('.mode-nav-dropdown');
|
||||||
|
if (!dropdown) return;
|
||||||
|
if (dropdown.classList.contains('open')) {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
} else {
|
||||||
|
openDropdown(dropdown);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.target.closest('.mode-nav-dropdown')) {
|
||||||
|
closeAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -15,6 +15,11 @@ let scannerCycles = 0;
|
|||||||
let scannerStartFreq = 118;
|
let scannerStartFreq = 118;
|
||||||
let scannerEndFreq = 137;
|
let scannerEndFreq = 137;
|
||||||
let scannerSignalActive = false;
|
let scannerSignalActive = false;
|
||||||
|
let lastScanProgress = null;
|
||||||
|
let scannerTotalSteps = 0;
|
||||||
|
let scannerMethod = null;
|
||||||
|
let scannerStepKhz = 25;
|
||||||
|
let lastScanFreq = null;
|
||||||
|
|
||||||
// Audio state
|
// Audio state
|
||||||
let isAudioPlaying = false;
|
let isAudioPlaying = false;
|
||||||
@@ -26,6 +31,9 @@ const MAX_AUDIO_RECONNECT = 3;
|
|||||||
let audioWebSocket = null;
|
let audioWebSocket = null;
|
||||||
let audioQueue = [];
|
let audioQueue = [];
|
||||||
let isWebSocketAudio = false;
|
let isWebSocketAudio = false;
|
||||||
|
let audioFetchController = null;
|
||||||
|
let audioUnlockRequested = false;
|
||||||
|
let scannerSnrThreshold = 8;
|
||||||
|
|
||||||
// Visualizer state
|
// Visualizer state
|
||||||
let visualizerContext = null;
|
let visualizerContext = null;
|
||||||
@@ -152,6 +160,7 @@ function startScanner() {
|
|||||||
const dwellSelect = document.getElementById('radioScanDwell');
|
const dwellSelect = document.getElementById('radioScanDwell');
|
||||||
const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10;
|
const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10;
|
||||||
const device = getSelectedDevice();
|
const device = getSelectedDevice();
|
||||||
|
const snrThreshold = scannerSnrThreshold || 12;
|
||||||
|
|
||||||
// Check if using agent mode
|
// Check if using agent mode
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
@@ -177,6 +186,10 @@ function startScanner() {
|
|||||||
scannerEndFreq = endFreq;
|
scannerEndFreq = endFreq;
|
||||||
scannerFreqsScanned = 0;
|
scannerFreqsScanned = 0;
|
||||||
scannerCycles = 0;
|
scannerCycles = 0;
|
||||||
|
lastScanProgress = null;
|
||||||
|
scannerTotalSteps = Math.max(1, Math.round(((endFreq - startFreq) * 1000) / step));
|
||||||
|
scannerStepKhz = step;
|
||||||
|
lastScanFreq = null;
|
||||||
|
|
||||||
// Update sidebar display
|
// Update sidebar display
|
||||||
updateScannerDisplay('STARTING...', 'var(--accent-orange)');
|
updateScannerDisplay('STARTING...', 'var(--accent-orange)');
|
||||||
@@ -213,7 +226,9 @@ function startScanner() {
|
|||||||
gain: gain,
|
gain: gain,
|
||||||
dwell_time: dwell,
|
dwell_time: dwell,
|
||||||
device: device,
|
device: device,
|
||||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false
|
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
||||||
|
snr_threshold: snrThreshold,
|
||||||
|
scan_method: 'power'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@@ -226,6 +241,30 @@ function startScanner() {
|
|||||||
isScannerRunning = true;
|
isScannerRunning = true;
|
||||||
isScannerPaused = false;
|
isScannerPaused = false;
|
||||||
scannerSignalActive = false;
|
scannerSignalActive = false;
|
||||||
|
scannerMethod = (scanResult.config && scanResult.config.scan_method) ? scanResult.config.scan_method : 'power';
|
||||||
|
if (scanResult.config) {
|
||||||
|
const cfgStart = parseFloat(scanResult.config.start_freq);
|
||||||
|
const cfgEnd = parseFloat(scanResult.config.end_freq);
|
||||||
|
const cfgStep = parseFloat(scanResult.config.step);
|
||||||
|
if (Number.isFinite(cfgStart)) scannerStartFreq = cfgStart;
|
||||||
|
if (Number.isFinite(cfgEnd)) scannerEndFreq = cfgEnd;
|
||||||
|
if (Number.isFinite(cfgStep)) scannerStepKhz = cfgStep;
|
||||||
|
scannerTotalSteps = Math.max(1, Math.round(((scannerEndFreq - scannerStartFreq) * 1000) / (scannerStepKhz || 1)));
|
||||||
|
|
||||||
|
const startInput = document.getElementById('radioScanStart');
|
||||||
|
if (startInput && Number.isFinite(cfgStart)) startInput.value = cfgStart.toFixed(3);
|
||||||
|
const endInput = document.getElementById('radioScanEnd');
|
||||||
|
if (endInput && Number.isFinite(cfgEnd)) endInput.value = cfgEnd.toFixed(3);
|
||||||
|
|
||||||
|
const rangeStart = document.getElementById('scannerRangeStart');
|
||||||
|
if (rangeStart && Number.isFinite(cfgStart)) rangeStart.textContent = cfgStart.toFixed(1);
|
||||||
|
const rangeEnd = document.getElementById('scannerRangeEnd');
|
||||||
|
if (rangeEnd && Number.isFinite(cfgEnd)) rangeEnd.textContent = cfgEnd.toFixed(1);
|
||||||
|
const mainRangeStart = document.getElementById('mainRangeStart');
|
||||||
|
if (mainRangeStart && Number.isFinite(cfgStart)) mainRangeStart.textContent = cfgStart.toFixed(1) + ' MHz';
|
||||||
|
const mainRangeEnd = document.getElementById('mainRangeEnd');
|
||||||
|
if (mainRangeEnd && Number.isFinite(cfgEnd)) mainRangeEnd.textContent = cfgEnd.toFixed(1) + ' MHz';
|
||||||
|
}
|
||||||
|
|
||||||
// Update controls (with null checks)
|
// Update controls (with null checks)
|
||||||
const startBtn = document.getElementById('scannerStartBtn');
|
const startBtn = document.getElementById('scannerStartBtn');
|
||||||
@@ -288,6 +327,12 @@ function stopScanner() {
|
|||||||
isScannerPaused = false;
|
isScannerPaused = false;
|
||||||
scannerSignalActive = false;
|
scannerSignalActive = false;
|
||||||
currentSignalLevel = 0;
|
currentSignalLevel = 0;
|
||||||
|
lastScanProgress = null;
|
||||||
|
scannerTotalSteps = 0;
|
||||||
|
scannerMethod = null;
|
||||||
|
scannerCycles = 0;
|
||||||
|
scannerFreqsScanned = 0;
|
||||||
|
lastScanFreq = null;
|
||||||
|
|
||||||
// Re-enable listen button (will be in local mode after stop)
|
// Re-enable listen button (will be in local mode after stop)
|
||||||
updateListenButtonState(false);
|
updateListenButtonState(false);
|
||||||
@@ -553,6 +598,13 @@ function handleScannerEvent(data) {
|
|||||||
case 'log':
|
case 'log':
|
||||||
if (data.entry && data.entry.type === 'scan_cycle') {
|
if (data.entry && data.entry.type === 'scan_cycle') {
|
||||||
scannerCycles++;
|
scannerCycles++;
|
||||||
|
lastScanProgress = null;
|
||||||
|
lastScanFreq = null;
|
||||||
|
if (scannerTotalSteps > 0) {
|
||||||
|
scannerFreqsScanned = scannerCycles * scannerTotalSteps;
|
||||||
|
const freqsEl = document.getElementById('mainFreqsScanned');
|
||||||
|
if (freqsEl) freqsEl.textContent = scannerFreqsScanned;
|
||||||
|
}
|
||||||
const cyclesEl = document.getElementById('mainScanCycles');
|
const cyclesEl = document.getElementById('mainScanCycles');
|
||||||
if (cyclesEl) cyclesEl.textContent = scannerCycles;
|
if (cyclesEl) cyclesEl.textContent = scannerCycles;
|
||||||
}
|
}
|
||||||
@@ -564,7 +616,89 @@ function handleScannerEvent(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFrequencyUpdate(data) {
|
function handleFrequencyUpdate(data) {
|
||||||
const freqStr = data.frequency.toFixed(3);
|
if (data.range_start !== undefined && data.range_end !== undefined) {
|
||||||
|
const newStart = parseFloat(data.range_start);
|
||||||
|
const newEnd = parseFloat(data.range_end);
|
||||||
|
if (Number.isFinite(newStart) && Number.isFinite(newEnd) && newEnd > newStart) {
|
||||||
|
scannerStartFreq = newStart;
|
||||||
|
scannerEndFreq = newEnd;
|
||||||
|
scannerTotalSteps = Math.max(1, Math.round(((scannerEndFreq - scannerStartFreq) * 1000) / (scannerStepKhz || 1)));
|
||||||
|
|
||||||
|
const rangeStart = document.getElementById('scannerRangeStart');
|
||||||
|
if (rangeStart) rangeStart.textContent = newStart.toFixed(1);
|
||||||
|
const rangeEnd = document.getElementById('scannerRangeEnd');
|
||||||
|
if (rangeEnd) rangeEnd.textContent = newEnd.toFixed(1);
|
||||||
|
const mainRangeStart = document.getElementById('mainRangeStart');
|
||||||
|
if (mainRangeStart) mainRangeStart.textContent = newStart.toFixed(1) + ' MHz';
|
||||||
|
const mainRangeEnd = document.getElementById('mainRangeEnd');
|
||||||
|
if (mainRangeEnd) mainRangeEnd.textContent = newEnd.toFixed(1) + ' MHz';
|
||||||
|
|
||||||
|
const startInput = document.getElementById('radioScanStart');
|
||||||
|
if (startInput && document.activeElement !== startInput) {
|
||||||
|
startInput.value = newStart.toFixed(3);
|
||||||
|
}
|
||||||
|
const endInput = document.getElementById('radioScanEnd');
|
||||||
|
if (endInput && document.activeElement !== endInput) {
|
||||||
|
endInput.value = newEnd.toFixed(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = scannerEndFreq - scannerStartFreq;
|
||||||
|
if (range <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveRange = scannerEndFreq - scannerStartFreq;
|
||||||
|
if (effectiveRange <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasProgress = data.progress !== undefined && Number.isFinite(data.progress);
|
||||||
|
const freqValue = (typeof data.frequency === 'number' && Number.isFinite(data.frequency))
|
||||||
|
? data.frequency
|
||||||
|
: null;
|
||||||
|
const stepMhz = Math.max(0.001, (scannerStepKhz || 1) / 1000);
|
||||||
|
const freqTolerance = stepMhz * 2;
|
||||||
|
|
||||||
|
let progressValue = null;
|
||||||
|
if (hasProgress) {
|
||||||
|
progressValue = data.progress;
|
||||||
|
const clamped = Math.max(0, Math.min(1, progressValue));
|
||||||
|
if (lastScanProgress !== null && clamped < lastScanProgress) {
|
||||||
|
const isCycleReset = lastScanProgress > 0.85 && clamped < 0.15;
|
||||||
|
if (!isCycleReset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastScanProgress = clamped;
|
||||||
|
} else if (freqValue !== null) {
|
||||||
|
if (lastScanFreq !== null && (freqValue + freqTolerance) < lastScanFreq) {
|
||||||
|
const nearEnd = lastScanFreq >= (scannerEndFreq - freqTolerance * 2);
|
||||||
|
const nearStart = freqValue <= (scannerStartFreq + freqTolerance * 2);
|
||||||
|
if (!nearEnd || !nearStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastScanFreq = freqValue;
|
||||||
|
progressValue = (freqValue - scannerStartFreq) / effectiveRange;
|
||||||
|
lastScanProgress = Math.max(0, Math.min(1, progressValue));
|
||||||
|
} else {
|
||||||
|
if (scannerMethod === 'power') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
progressValue = 0;
|
||||||
|
lastScanProgress = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedProgress = Math.max(0, Math.min(1, progressValue));
|
||||||
|
|
||||||
|
const displayFreq = (freqValue !== null
|
||||||
|
&& freqValue >= (scannerStartFreq - freqTolerance)
|
||||||
|
&& freqValue <= (scannerEndFreq + freqTolerance))
|
||||||
|
? freqValue
|
||||||
|
: scannerStartFreq + (clampedProgress * effectiveRange);
|
||||||
|
const freqStr = displayFreq.toFixed(3);
|
||||||
|
|
||||||
const currentFreq = document.getElementById('scannerCurrentFreq');
|
const currentFreq = document.getElementById('scannerCurrentFreq');
|
||||||
if (currentFreq) currentFreq.textContent = freqStr + ' MHz';
|
if (currentFreq) currentFreq.textContent = freqStr + ' MHz';
|
||||||
@@ -572,17 +706,25 @@ function handleFrequencyUpdate(data) {
|
|||||||
const mainFreq = document.getElementById('mainScannerFreq');
|
const mainFreq = document.getElementById('mainScannerFreq');
|
||||||
if (mainFreq) mainFreq.textContent = freqStr;
|
if (mainFreq) mainFreq.textContent = freqStr;
|
||||||
|
|
||||||
|
if (scannerTotalSteps > 0) {
|
||||||
|
const stepSize = Math.max(1, scannerStepKhz || 1);
|
||||||
|
const stepIndex = Math.max(0, Math.round(((displayFreq - scannerStartFreq) * 1000) / stepSize));
|
||||||
|
const nextScanned = (scannerCycles * scannerTotalSteps)
|
||||||
|
+ Math.min(scannerTotalSteps, stepIndex);
|
||||||
|
scannerFreqsScanned = Math.max(scannerFreqsScanned, nextScanned);
|
||||||
|
const freqsEl = document.getElementById('mainFreqsScanned');
|
||||||
|
if (freqsEl) freqsEl.textContent = scannerFreqsScanned;
|
||||||
|
}
|
||||||
|
|
||||||
// Update progress bar
|
// Update progress bar
|
||||||
const progress = ((data.frequency - scannerStartFreq) / (scannerEndFreq - scannerStartFreq)) * 100;
|
const progress = Math.max(0, Math.min(100, clampedProgress * 100));
|
||||||
const progressBar = document.getElementById('scannerProgressBar');
|
const progressBar = document.getElementById('scannerProgressBar');
|
||||||
if (progressBar) progressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
|
if (progressBar) progressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
|
||||||
|
|
||||||
const mainProgressBar = document.getElementById('mainProgressBar');
|
const mainProgressBar = document.getElementById('mainProgressBar');
|
||||||
if (mainProgressBar) mainProgressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
|
if (mainProgressBar) mainProgressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
|
||||||
|
|
||||||
scannerFreqsScanned++;
|
// freqs scanned updated via progress above
|
||||||
const freqsEl = document.getElementById('mainFreqsScanned');
|
|
||||||
if (freqsEl) freqsEl.textContent = scannerFreqsScanned;
|
|
||||||
|
|
||||||
// Update level meter if present
|
// Update level meter if present
|
||||||
if (data.level !== undefined) {
|
if (data.level !== undefined) {
|
||||||
@@ -613,6 +755,15 @@ function handleFrequencyUpdate(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSignalFound(data) {
|
function handleSignalFound(data) {
|
||||||
|
// Only treat signals as "interesting" if they exceed threshold and match modulation
|
||||||
|
const threshold = data.threshold !== undefined ? data.threshold : signalLevelThreshold;
|
||||||
|
if (data.level !== undefined && threshold !== undefined && data.level < threshold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.modulation && currentModulation && data.modulation !== currentModulation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
scannerSignalCount++;
|
scannerSignalCount++;
|
||||||
scannerSignalActive = true;
|
scannerSignalActive = true;
|
||||||
const freqStr = data.frequency.toFixed(3);
|
const freqStr = data.frequency.toFixed(3);
|
||||||
@@ -650,6 +801,10 @@ function handleSignalFound(data) {
|
|||||||
const streamUrl = getStreamUrl(data.frequency, data.modulation);
|
const streamUrl = getStreamUrl(data.frequency, data.modulation);
|
||||||
console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz');
|
console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz');
|
||||||
scannerAudio.src = streamUrl;
|
scannerAudio.src = streamUrl;
|
||||||
|
scannerAudio.preload = 'auto';
|
||||||
|
scannerAudio.autoplay = true;
|
||||||
|
scannerAudio.muted = false;
|
||||||
|
scannerAudio.load();
|
||||||
// Apply current volume from knob
|
// Apply current volume from knob
|
||||||
const volumeKnob = document.getElementById('radioVolumeKnob');
|
const volumeKnob = document.getElementById('radioVolumeKnob');
|
||||||
if (volumeKnob && volumeKnob._knob) {
|
if (volumeKnob && volumeKnob._knob) {
|
||||||
@@ -658,7 +813,7 @@ function handleSignalFound(data) {
|
|||||||
const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
|
const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
|
||||||
scannerAudio.volume = knobValue / 100;
|
scannerAudio.volume = knobValue / 100;
|
||||||
}
|
}
|
||||||
scannerAudio.play().catch(e => console.warn('[SCANNER] Audio autoplay blocked:', e));
|
attemptAudioPlay(scannerAudio);
|
||||||
// Initialize audio visualizer to feed signal levels to synthesizer
|
// Initialize audio visualizer to feed signal levels to synthesizer
|
||||||
initAudioVisualizer();
|
initAudioVisualizer();
|
||||||
}
|
}
|
||||||
@@ -1700,14 +1855,13 @@ function stopSynthesizer() {
|
|||||||
function getStreamUrl(freq, mod) {
|
function getStreamUrl(freq, mod) {
|
||||||
const frequency = freq || parseFloat(document.getElementById('radioScanStart')?.value) || 118.0;
|
const frequency = freq || parseFloat(document.getElementById('radioScanStart')?.value) || 118.0;
|
||||||
const modulation = mod || currentModulation || 'am';
|
const modulation = mod || currentModulation || 'am';
|
||||||
const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30;
|
return `/listening/audio/stream?fresh=1&freq=${frequency}&mod=${modulation}&t=${Date.now()}`;
|
||||||
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() {
|
function initListeningPost() {
|
||||||
checkScannerTools();
|
checkScannerTools();
|
||||||
checkAudioTools();
|
checkAudioTools();
|
||||||
|
initSnrThresholdControl();
|
||||||
|
|
||||||
// WebSocket audio disabled for now - using HTTP streaming
|
// WebSocket audio disabled for now - using HTTP streaming
|
||||||
// initWebSocketAudio();
|
// initWebSocketAudio();
|
||||||
@@ -1811,6 +1965,29 @@ function initListeningPost() {
|
|||||||
checkIncomingTuneRequest();
|
checkIncomingTuneRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initSnrThresholdControl() {
|
||||||
|
const slider = document.getElementById('snrThresholdSlider');
|
||||||
|
const valueEl = document.getElementById('snrThresholdValue');
|
||||||
|
if (!slider || !valueEl) return;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('scannerSnrThreshold');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = parseInt(stored, 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
scannerSnrThreshold = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slider.value = scannerSnrThreshold;
|
||||||
|
valueEl.textContent = String(scannerSnrThreshold);
|
||||||
|
|
||||||
|
slider.addEventListener('input', () => {
|
||||||
|
scannerSnrThreshold = parseInt(slider.value, 10);
|
||||||
|
valueEl.textContent = String(scannerSnrThreshold);
|
||||||
|
localStorage.setItem('scannerSnrThreshold', String(scannerSnrThreshold));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for incoming tune request from Spy Stations or other pages
|
* Check for incoming tune request from Spy Stations or other pages
|
||||||
*/
|
*/
|
||||||
@@ -1855,6 +2032,13 @@ function toggleDirectListen() {
|
|||||||
if (isDirectListening) {
|
if (isDirectListening) {
|
||||||
stopDirectListen();
|
stopDirectListen();
|
||||||
} else {
|
} else {
|
||||||
|
const audioPlayer = document.getElementById('scannerAudioPlayer');
|
||||||
|
if (audioPlayer) {
|
||||||
|
audioPlayer.muted = false;
|
||||||
|
audioPlayer.autoplay = true;
|
||||||
|
audioPlayer.preload = 'auto';
|
||||||
|
}
|
||||||
|
audioUnlockRequested = true;
|
||||||
// First press - start immediately, don't debounce
|
// First press - start immediately, don't debounce
|
||||||
startDirectListenImmediate();
|
startDirectListenImmediate();
|
||||||
}
|
}
|
||||||
@@ -2057,10 +2241,16 @@ async function _startDirectListenInternal() {
|
|||||||
|
|
||||||
const freqInput = document.getElementById('radioScanStart');
|
const freqInput = document.getElementById('radioScanStart');
|
||||||
const freq = freqInput ? parseFloat(freqInput.value) : 118.0;
|
const freq = freqInput ? parseFloat(freqInput.value) : 118.0;
|
||||||
const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30;
|
const squelchValue = parseInt(document.getElementById('radioSquelchValue')?.textContent);
|
||||||
|
const squelch = Number.isFinite(squelchValue) ? squelchValue : 0;
|
||||||
const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
|
const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
|
||||||
|
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||||
|
const sdrType = typeof getSelectedSDRType === 'function'
|
||||||
|
? getSelectedSDRType()
|
||||||
|
: getSelectedSDRTypeForScanner();
|
||||||
|
const biasT = typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false;
|
||||||
|
|
||||||
console.log('[LISTEN] Tuning to:', freq, 'MHz', currentModulation);
|
console.log('[LISTEN] Tuning to:', freq, 'MHz', currentModulation, 'device', device, 'sdr', sdrType);
|
||||||
|
|
||||||
const listenBtn = document.getElementById('radioListenBtn');
|
const listenBtn = document.getElementById('radioListenBtn');
|
||||||
if (listenBtn) {
|
if (listenBtn) {
|
||||||
@@ -2091,8 +2281,11 @@ async function _startDirectListenInternal() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
frequency: freq,
|
frequency: freq,
|
||||||
modulation: currentModulation,
|
modulation: currentModulation,
|
||||||
squelch: squelch,
|
squelch: 0,
|
||||||
gain: gain
|
gain: gain,
|
||||||
|
device: device,
|
||||||
|
sdr_type: sdrType,
|
||||||
|
bias_t: biasT
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2111,9 +2304,13 @@ async function _startDirectListenInternal() {
|
|||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
// Connect to new stream
|
// Connect to new stream
|
||||||
const streamUrl = `/listening/audio/stream?t=${Date.now()}`;
|
const streamUrl = `/listening/audio/stream?fresh=1&t=${Date.now()}`;
|
||||||
console.log('[LISTEN] Connecting to stream:', streamUrl);
|
console.log('[LISTEN] Connecting to stream:', streamUrl);
|
||||||
audioPlayer.src = streamUrl;
|
audioPlayer.src = streamUrl;
|
||||||
|
audioPlayer.preload = 'auto';
|
||||||
|
audioPlayer.autoplay = true;
|
||||||
|
audioPlayer.muted = false;
|
||||||
|
audioPlayer.load();
|
||||||
|
|
||||||
// Apply current volume from knob
|
// Apply current volume from knob
|
||||||
const volumeKnob = document.getElementById('radioVolumeKnob');
|
const volumeKnob = document.getElementById('radioVolumeKnob');
|
||||||
@@ -2127,13 +2324,20 @@ async function _startDirectListenInternal() {
|
|||||||
// Wait for audio to be ready then play
|
// Wait for audio to be ready then play
|
||||||
audioPlayer.oncanplay = () => {
|
audioPlayer.oncanplay = () => {
|
||||||
console.log('[LISTEN] Audio can play');
|
console.log('[LISTEN] Audio can play');
|
||||||
audioPlayer.play().catch(e => console.warn('[LISTEN] Autoplay blocked:', e));
|
attemptAudioPlay(audioPlayer);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Also try to play immediately (some browsers need this)
|
// Also try to play immediately (some browsers need this)
|
||||||
audioPlayer.play().catch(e => {
|
attemptAudioPlay(audioPlayer);
|
||||||
console.log('[LISTEN] Initial play blocked, waiting for canplay');
|
|
||||||
});
|
// If stream is slow, retry play and prompt for manual unlock
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (!isDirectListening || !audioPlayer) return;
|
||||||
|
if (audioPlayer.readyState > 0) return;
|
||||||
|
audioPlayer.load();
|
||||||
|
attemptAudioPlay(audioPlayer);
|
||||||
|
showAudioUnlock(audioPlayer);
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
// Initialize audio visualizer to feed signal levels to synthesizer
|
// Initialize audio visualizer to feed signal levels to synthesizer
|
||||||
initAudioVisualizer();
|
initAudioVisualizer();
|
||||||
@@ -2152,6 +2356,153 @@ async function _startDirectListenInternal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attemptAudioPlay(audioPlayer) {
|
||||||
|
if (!audioPlayer) return;
|
||||||
|
audioPlayer.play().then(() => {
|
||||||
|
hideAudioUnlock();
|
||||||
|
}).catch(() => {
|
||||||
|
// Autoplay likely blocked; show manual unlock
|
||||||
|
showAudioUnlock(audioPlayer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAudioUnlock(audioPlayer) {
|
||||||
|
const unlockBtn = document.getElementById('audioUnlockBtn');
|
||||||
|
if (!unlockBtn || !audioUnlockRequested) return;
|
||||||
|
unlockBtn.style.display = 'block';
|
||||||
|
unlockBtn.onclick = () => {
|
||||||
|
audioPlayer.muted = false;
|
||||||
|
audioPlayer.play().then(() => {
|
||||||
|
hideAudioUnlock();
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAudioUnlock() {
|
||||||
|
const unlockBtn = document.getElementById('audioUnlockBtn');
|
||||||
|
if (unlockBtn) {
|
||||||
|
unlockBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
audioUnlockRequested = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFetchAudioStream(streamUrl, audioPlayer) {
|
||||||
|
if (!window.MediaSource) {
|
||||||
|
console.warn('[LISTEN] MediaSource not supported for fetch fallback');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort any previous fetch stream
|
||||||
|
if (audioFetchController) {
|
||||||
|
audioFetchController.abort();
|
||||||
|
}
|
||||||
|
audioFetchController = new AbortController();
|
||||||
|
|
||||||
|
// Reset audio element for MediaSource
|
||||||
|
try {
|
||||||
|
audioPlayer.pause();
|
||||||
|
} catch (e) {}
|
||||||
|
audioPlayer.removeAttribute('src');
|
||||||
|
audioPlayer.load();
|
||||||
|
|
||||||
|
const mediaSource = new MediaSource();
|
||||||
|
audioPlayer.src = URL.createObjectURL(mediaSource);
|
||||||
|
audioPlayer.muted = false;
|
||||||
|
audioPlayer.autoplay = true;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
mediaSource.addEventListener('sourceopen', async () => {
|
||||||
|
let sourceBuffer;
|
||||||
|
try {
|
||||||
|
sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LISTEN] Failed to create source buffer:', e);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let attempts = 0;
|
||||||
|
while (attempts < 5) {
|
||||||
|
attempts += 1;
|
||||||
|
const response = await fetch(streamUrl, {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: audioFetchController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
console.warn('[LISTEN] Stream not ready (204), retrying...', attempts);
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
console.warn('[LISTEN] Fetch stream response invalid', response.status);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const appendChunk = async (chunk) => {
|
||||||
|
if (!chunk || chunk.length === 0) return;
|
||||||
|
if (!sourceBuffer.updating) {
|
||||||
|
sourceBuffer.appendBuffer(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise(r => sourceBuffer.addEventListener('updateend', r, { once: true }));
|
||||||
|
sourceBuffer.appendBuffer(chunk);
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
await appendChunk(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(false);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError') {
|
||||||
|
console.error('[LISTEN] Fetch stream error:', e);
|
||||||
|
}
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWebSocketListen(config, audioPlayer) {
|
||||||
|
const selectedType = typeof getSelectedSDRType === 'function'
|
||||||
|
? getSelectedSDRType()
|
||||||
|
: getSelectedSDRTypeForScanner();
|
||||||
|
if (selectedType && selectedType !== 'rtlsdr') {
|
||||||
|
console.warn('[LISTEN] WebSocket audio supports RTL-SDR only');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Stop HTTP audio stream before switching
|
||||||
|
await fetch('/listening/audio/stop', { method: 'POST' });
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Reset audio element for MediaSource
|
||||||
|
try {
|
||||||
|
audioPlayer.pause();
|
||||||
|
} catch (e) {}
|
||||||
|
audioPlayer.removeAttribute('src');
|
||||||
|
audioPlayer.load();
|
||||||
|
|
||||||
|
const ws = initWebSocketAudio();
|
||||||
|
if (!ws) return;
|
||||||
|
|
||||||
|
// Ensure MediaSource is set up
|
||||||
|
setupMediaSource(audioPlayer);
|
||||||
|
sendWebSocketCommand('start', config);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop direct listening
|
* Stop direct listening
|
||||||
*/
|
*/
|
||||||
@@ -2179,6 +2530,10 @@ function stopDirectListen() {
|
|||||||
audioPlayer.src = '';
|
audioPlayer.src = '';
|
||||||
}
|
}
|
||||||
audioQueue = [];
|
audioQueue = [];
|
||||||
|
if (audioFetchController) {
|
||||||
|
audioFetchController.abort();
|
||||||
|
audioFetchController = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop via WebSocket if connected
|
// Stop via WebSocket if connected
|
||||||
if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) {
|
if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) {
|
||||||
@@ -2207,12 +2562,10 @@ function updateDirectListenUI(isPlaying, freq) {
|
|||||||
if (listenBtn) {
|
if (listenBtn) {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
listenBtn.innerHTML = Icons.stop('icon--sm') + ' STOP';
|
listenBtn.innerHTML = Icons.stop('icon--sm') + ' STOP';
|
||||||
listenBtn.style.background = 'var(--accent-red)';
|
listenBtn.classList.add('active');
|
||||||
listenBtn.style.borderColor = 'var(--accent-red)';
|
|
||||||
} else {
|
} else {
|
||||||
listenBtn.innerHTML = Icons.headphones('icon--sm') + ' LISTEN';
|
listenBtn.innerHTML = Icons.headphones('icon--sm') + ' LISTEN';
|
||||||
listenBtn.style.background = 'var(--accent-purple)';
|
listenBtn.classList.remove('active');
|
||||||
listenBtn.style.borderColor = 'var(--accent-purple)';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2596,4 +2949,3 @@ window.removeBookmark = removeBookmark;
|
|||||||
window.tuneToFrequency = tuneToFrequency;
|
window.tuneToFrequency = tuneToFrequency;
|
||||||
window.clearScannerLog = clearScannerLog;
|
window.clearScannerLog = clearScannerLog;
|
||||||
window.exportScannerLog = exportScannerLog;
|
window.exportScannerLog = exportScannerLog;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
|
||||||
<script>
|
<script>
|
||||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||||
@@ -51,6 +52,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% set active_mode = 'adsb' %}
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
|
||||||
<!-- Slim Statistics Bar -->
|
<!-- Slim Statistics Bar -->
|
||||||
<div class="stats-strip">
|
<div class="stats-strip">
|
||||||
<div class="stats-strip-inner">
|
<div class="stats-strip-inner">
|
||||||
@@ -2255,7 +2259,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (type === 'not_installed') {
|
if (type === 'not_installed') {
|
||||||
@@ -2340,7 +2344,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
`;
|
`;
|
||||||
warning.innerHTML = `
|
warning.innerHTML = `
|
||||||
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${typeList} Detected - readsb Required</div>
|
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${typeList} Detected - readsb Required</div>
|
||||||
@@ -2348,7 +2352,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
<details style="font-size: 11px;">
|
<details style="font-size: 11px;">
|
||||||
<summary style="cursor: pointer; margin-bottom: 8px;">Installation Instructions</summary>
|
<summary style="cursor: pointer; margin-bottom: 8px;">Installation Instructions</summary>
|
||||||
<div style="background: rgba(0,0,0,0.1); padding: 10px; border-radius: 4px; margin-top: 5px;">
|
<div style="background: rgba(0,0,0,0.1); padding: 10px; border-radius: 4px; margin-top: 5px;">
|
||||||
<code style="display: block; white-space: pre-wrap; font-family: 'JetBrains Mono', monospace; font-size: 10px;">sudo apt install build-essential libsoapysdr-dev librtlsdr-dev
|
<code style="display: block; white-space: pre-wrap; font-family: var(--font-mono); font-size: 10px;">sudo apt install build-essential libsoapysdr-dev librtlsdr-dev
|
||||||
git clone https://github.com/wiedehopf/readsb.git
|
git clone https://github.com/wiedehopf/readsb.git
|
||||||
cd readsb
|
cd readsb
|
||||||
make HAVE_SOAPYSDR=1
|
make HAVE_SOAPYSDR=1
|
||||||
@@ -4200,7 +4204,7 @@ sudo make install</code>
|
|||||||
background: var(--bg-tertiary, #252525);
|
background: var(--bg-tertiary, #252525);
|
||||||
}
|
}
|
||||||
.squawk-current-code {
|
.squawk-current-code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
@@ -4247,7 +4251,7 @@ sudo make install</code>
|
|||||||
background: rgba(255, 0, 0, 0.2);
|
background: rgba(255, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
.squawk-ref-table .squawk-code {
|
.squawk-ref-table .squawk-code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
@@ -4395,7 +4399,7 @@ sudo make install</code>
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.watchlist-value {
|
.watchlist-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -4463,7 +4467,7 @@ sudo make install</code>
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.agent-select-sm:focus {
|
.agent-select-sm:focus {
|
||||||
@@ -4517,6 +4521,7 @@ sudo make install</code>
|
|||||||
|
|
||||||
<!-- Agent Manager -->
|
<!-- Agent Manager -->
|
||||||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
// ADS-B specific agent integration
|
// ADS-B specific agent integration
|
||||||
let adsbCurrentAgent = 'local';
|
let adsbCurrentAgent = 'local';
|
||||||
|
|||||||
@@ -4,8 +4,13 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ADS-B History // INTERCEPT</title>
|
<title>ADS-B History // INTERCEPT</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -22,6 +27,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% set active_mode = 'adsb' %}
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
|
||||||
<main class="history-shell">
|
<main class="history-shell">
|
||||||
<section class="summary-strip">
|
<section class="summary-strip">
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
@@ -761,5 +769,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<style>
|
<style>
|
||||||
.agents-container {
|
.agents-container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
.agent-url {
|
.agent-url {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: #888;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus {
|
.form-group input:focus {
|
||||||
@@ -300,6 +301,8 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
|
||||||
<div class="agents-container">
|
<div class="agents-container">
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="#" onclick="history.back(); return false;" class="back-link">
|
<a href="#" onclick="history.back(); return false;" class="back-link">
|
||||||
@@ -584,5 +587,6 @@
|
|||||||
// Load agents on page load
|
// Load agents on page load
|
||||||
document.addEventListener('DOMContentLoaded', loadAgents);
|
document.addEventListener('DOMContentLoaded', loadAgents);
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<script>
|
<script>
|
||||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||||
</script>
|
</script>
|
||||||
@@ -51,6 +52,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% set active_mode = 'ais' %}
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
|
||||||
<div class="stats-strip">
|
<div class="stats-strip">
|
||||||
<div class="stats-strip-inner">
|
<div class="stats-strip-inner">
|
||||||
<div class="strip-stat">
|
<div class="strip-stat">
|
||||||
@@ -1495,7 +1499,7 @@
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.agent-select-sm:focus {
|
.agent-select-sm:focus {
|
||||||
@@ -1549,6 +1553,7 @@
|
|||||||
|
|
||||||
<!-- Agent Manager -->
|
<!-- Agent Manager -->
|
||||||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
// AIS-specific agent integration
|
// AIS-specific agent integration
|
||||||
let aisCurrentAgent = 'local';
|
let aisCurrentAgent = 'local';
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{#
|
||||||
|
Card/Panel Component
|
||||||
|
Reusable container with optional header and footer
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- title: Optional card header title
|
||||||
|
- indicator: If true, shows status indicator dot in header
|
||||||
|
- indicator_active: If true, indicator is active/green
|
||||||
|
- no_padding: If true, removes body padding
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
{% if title %}
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
{% if indicator %}
|
||||||
|
<div class="panel-indicator {% if indicator_active %}active{% endif %}"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="panel-content{% if no_padding %}" style="padding: 0;{% else %}{% endif %}">
|
||||||
|
{{ caller() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{#
|
||||||
|
Empty State Component
|
||||||
|
Display when no data is available
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- icon: Optional SVG icon (default: generic empty icon)
|
||||||
|
- title: Main message (default: "No data")
|
||||||
|
- description: Optional helper text
|
||||||
|
- action_text: Optional button text
|
||||||
|
- action_onclick: Optional button onclick handler
|
||||||
|
- action_href: Optional button link
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
{% if icon %}
|
||||||
|
{{ icon|safe }}
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M8 12h8"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="empty-state-title">{{ title|default('No data') }}</div>
|
||||||
|
{% if description %}
|
||||||
|
<div class="empty-state-description">{{ description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if action_text %}
|
||||||
|
<div class="empty-state-action">
|
||||||
|
{% if action_href %}
|
||||||
|
<a href="{{ action_href }}" class="btn btn-primary btn-sm">{{ action_text }}</a>
|
||||||
|
{% elif action_onclick %}
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="{{ action_onclick }}">{{ action_text }}</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{#
|
||||||
|
Loading State Component
|
||||||
|
Display while data is being fetched
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- text: Optional loading text (default: "Loading...")
|
||||||
|
- size: 'sm', 'md', or 'lg' (default: 'md')
|
||||||
|
- overlay: If true, renders as full overlay
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% if overlay %}
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="spinner {% if size == 'sm' %}spinner-sm{% elif size == 'lg' %}spinner-lg{% endif %}"></div>
|
||||||
|
{% if text %}
|
||||||
|
<div class="loading-text mt-3 text-secondary text-sm">{{ text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="loading-inline flex items-center gap-3">
|
||||||
|
<div class="spinner {% if size == 'sm' %}spinner-sm{% elif size == 'lg' %}spinner-lg{% endif %}"></div>
|
||||||
|
{% if text %}
|
||||||
|
<span class="text-secondary text-sm">{{ text }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{#
|
||||||
|
Stats Strip Component
|
||||||
|
Horizontal bar displaying key metrics
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- stats: List of stat objects with 'id', 'value', 'label', and optional 'title'
|
||||||
|
- show_divider: Show divider after stats (default: true)
|
||||||
|
- status_dot_id: Optional ID for status indicator dot
|
||||||
|
- status_text_id: Optional ID for status text
|
||||||
|
- time_id: Optional ID for time display
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div class="stats-strip">
|
||||||
|
<div class="stats-strip-inner">
|
||||||
|
{% for stat in stats %}
|
||||||
|
<div class="strip-stat" {% if stat.title %}title="{{ stat.title }}"{% endif %}>
|
||||||
|
<span class="strip-value" id="{{ stat.id }}">{{ stat.value|default('0') }}</span>
|
||||||
|
<span class="strip-label">{{ stat.label }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if show_divider|default(true) %}
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Additional content from caller #}
|
||||||
|
{% if caller is defined %}
|
||||||
|
{{ caller() }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status_dot_id or status_text_id %}
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<div class="strip-status">
|
||||||
|
{% if status_dot_id %}
|
||||||
|
<div class="status-dot inactive" id="{{ status_dot_id }}"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if status_text_id %}
|
||||||
|
<span id="{{ status_text_id }}">STANDBY</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if time_id %}
|
||||||
|
<div class="strip-time" id="{{ time_id }}">--:--:-- UTC</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{#
|
||||||
|
Status Badge Component
|
||||||
|
Compact status indicator with dot and text
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- status: 'online', 'offline', 'warning', 'error' (default: 'offline')
|
||||||
|
- text: Status text to display
|
||||||
|
- id: Optional ID for the text element (for JS updates)
|
||||||
|
- dot_id: Optional ID for the dot element (for JS updates)
|
||||||
|
- pulse: If true, adds pulse animation to dot
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set status_class = {
|
||||||
|
'online': 'online',
|
||||||
|
'active': 'online',
|
||||||
|
'offline': 'offline',
|
||||||
|
'warning': 'warning',
|
||||||
|
'error': 'error',
|
||||||
|
'inactive': 'inactive'
|
||||||
|
}.get(status|default('offline'), 'inactive') %}
|
||||||
|
|
||||||
|
<div class="status-badge flex items-center gap-2">
|
||||||
|
<div class="status-dot {{ status_class }}{% if pulse %} pulse{% endif %}"
|
||||||
|
{% if dot_id %}id="{{ dot_id }}"{% endif %}></div>
|
||||||
|
<span class="text-sm"
|
||||||
|
{% if id %}id="{{ id }}"{% endif %}>{{ text|default('Unknown') }}</span>
|
||||||
|
</div>
|
||||||
+96
-120
@@ -22,7 +22,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Leaflet.js for APRS map - Conditional CDN/Local loading -->
|
<!-- Leaflet.js for APRS map - Conditional CDN/Local loading -->
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝</pre>
|
╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝</pre>
|
||||||
<div style="margin: 25px 0; padding: 15px; background: #0a0a0a; border-left: 3px solid var(--accent-red);">
|
<div style="margin: 25px 0; padding: 15px; background: #0a0a0a; border-left: 3px solid var(--accent-red);">
|
||||||
<p
|
<p
|
||||||
style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #888; text-align: left; margin: 0;">
|
style="font-family: var(--font-mono); font-size: 11px; color: #888; text-align: left; margin: 0;">
|
||||||
<span style="color: var(--accent-red);">root@intercepted:</span><span
|
<span style="color: var(--accent-red);">root@intercepted:</span><span
|
||||||
style="color: var(--accent-cyan);">~#</span> sudo access --grant-permission<br>
|
style="color: var(--accent-cyan);">~#</span> sudo access --grant-permission<br>
|
||||||
<span style="color: #666;">[sudo] password for user: ********</span><br>
|
<span style="color: #666;">[sudo] password for user: ********</span><br>
|
||||||
@@ -348,107 +348,9 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Mode Navigation Bar -->
|
<!-- Mode Navigation Bar -->
|
||||||
<nav class="mode-nav">
|
{% set is_index_page = true %}
|
||||||
<div class="mode-nav-dropdown" data-group="sdr">
|
{% set active_mode = 'pager' %}
|
||||||
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('sdr')">
|
{% include 'partials/nav.html' with context %}
|
||||||
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>
|
|
||||||
<span class="nav-label">SDR / RF</span>
|
|
||||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
|
||||||
</button>
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
|
||||||
<button class="mode-nav-btn active" onclick="switchMode('pager')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="nav-label">Pager</span></button>
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('sensor')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span><span class="nav-label">433MHz</span></button>
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('rtlamr')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span><span class="nav-label">Meters</span></button>
|
|
||||||
<a href="/adsb/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="nav-label">Aircraft</span></a>
|
|
||||||
<a href="/ais/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="nav-label">Vessels</span></a>
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="nav-label">APRS</span></button>
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="nav-label">Listening Post</span></button>
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('spystations')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="nav-label">Spy Stations</span></button>
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('meshtastic')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="nav-label">Meshtastic</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mode-nav-dropdown" data-group="wireless">
|
|
||||||
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('wireless')">
|
|
||||||
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
|
||||||
<span class="nav-label">Wireless</span>
|
|
||||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
|
||||||
</button>
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('wifi')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span><span class="nav-label">WiFi</span></button>
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('bluetooth')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span><span class="nav-label">Bluetooth</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mode-nav-dropdown" data-group="security">
|
|
||||||
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('security')">
|
|
||||||
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
|
||||||
<span class="nav-label">Security</span>
|
|
||||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
|
||||||
</button>
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('tscm')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span><span class="nav-label">TSCM</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mode-nav-dropdown" data-group="space">
|
|
||||||
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('space')">
|
|
||||||
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
|
|
||||||
<span class="nav-label">Space</span>
|
|
||||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
|
||||||
</button>
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="nav-label">Satellite</span></button>
|
|
||||||
<button class="mode-nav-btn" onclick="switchMode('sstv')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span><span class="nav-label">ISS SSTV</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mode-nav-actions">
|
|
||||||
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn"
|
|
||||||
style="display: none;">
|
|
||||||
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span><span class="nav-label">Full Dashboard</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-utilities">
|
|
||||||
<div class="nav-clock">
|
|
||||||
<span class="utc-label">UTC</span>
|
|
||||||
<span class="utc-time" id="headerUtcTime">--:--:--</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-divider"></div>
|
|
||||||
<div class="nav-tools">
|
|
||||||
<button class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations">
|
|
||||||
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
|
|
||||||
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
|
|
||||||
</button>
|
|
||||||
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
|
|
||||||
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
|
|
||||||
<span class="icon-sun icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
|
||||||
</button>
|
|
||||||
<a href="/controller/monitor" class="nav-tool-btn" title="Network Monitor - Multi-Agent View" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span></a>
|
|
||||||
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span></a>
|
|
||||||
<button class="nav-tool-btn" onclick="showSettings()" title="Settings"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span></button>
|
|
||||||
<a href="https://buymeacoffee.com/smittix" target="_blank" rel="noopener noreferrer" class="nav-tool-btn nav-tool-btn--donate" title="Support the Project"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></span></a>
|
|
||||||
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
|
|
||||||
<button class="nav-tool-btn" onclick="logout(event)" title="Logout">
|
|
||||||
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Mobile Navigation Bar (simplified mode switching) -->
|
|
||||||
<nav class="mobile-nav-bar" id="mobileNavBar">
|
|
||||||
<button class="mobile-nav-btn active" data-mode="pager" onclick="switchMode('pager')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> Pager</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="sensor" onclick="switchMode('sensor')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span> 433MHz</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="rtlamr" onclick="switchMode('rtlamr')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span> Meters</button>
|
|
||||||
<a href="/adsb/dashboard" class="mobile-nav-btn" style="text-decoration: none;"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span> Aircraft</a>
|
|
||||||
<a href="/ais/dashboard" class="mobile-nav-btn" style="text-decoration: none;"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span> Vessels</a>
|
|
||||||
<button class="mobile-nav-btn" data-mode="aprs" onclick="switchMode('aprs')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span> APRS</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="wifi" onclick="switchMode('wifi')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> WiFi</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="bluetooth" onclick="switchMode('bluetooth')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span> BT</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span> TSCM</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> Sat</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="sstv" onclick="switchMode('sstv')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg></span> SSTV</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span> Scanner</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="spystations" onclick="switchMode('spystations')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span> Spy</button>
|
|
||||||
<button class="mobile-nav-btn" data-mode="meshtastic" onclick="switchMode('meshtastic')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span> Mesh</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Mobile Drawer Overlay -->
|
<!-- Mobile Drawer Overlay -->
|
||||||
<div class="drawer-overlay" id="drawerOverlay"></div>
|
<div class="drawer-overlay" id="drawerOverlay"></div>
|
||||||
@@ -1087,7 +989,7 @@
|
|||||||
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
|
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
|
||||||
PACKET LOG</h5>
|
PACKET LOG</h5>
|
||||||
<div id="aprsPacketLog"
|
<div id="aprsPacketLog"
|
||||||
style="flex: 1; overflow-y: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
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;">
|
||||||
<div style="color: var(--text-muted);">Waiting for packets...</div>
|
<div style="color: var(--text-muted);">Waiting for packets...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1107,7 +1009,7 @@
|
|||||||
STOPPED</div>
|
STOPPED</div>
|
||||||
<div style="display: flex; justify-content: center; align-items: baseline; gap: 8px;">
|
<div style="display: flex; justify-content: center; align-items: baseline; gap: 8px;">
|
||||||
<div class="freq-digits" id="mainScannerFreq"
|
<div class="freq-digits" id="mainScannerFreq"
|
||||||
style="font-size: 52px; font-weight: bold; color: var(--accent-cyan); text-shadow: 0 0 30px var(--accent-cyan); font-family: 'JetBrains Mono', monospace; letter-spacing: 3px;">
|
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</div>
|
118.000</div>
|
||||||
<span class="freq-unit"
|
<span class="freq-unit"
|
||||||
style="font-size: 20px; color: var(--text-secondary); font-weight: 500;">MHz</span>
|
style="font-size: 20px; color: var(--text-secondary); font-weight: 500;">MHz</span>
|
||||||
@@ -1153,6 +1055,10 @@
|
|||||||
Audio Output</div>
|
Audio Output</div>
|
||||||
</div>
|
</div>
|
||||||
<audio id="scannerAudioPlayer" style="width: 100%; height: 28px;" controls></audio>
|
<audio id="scannerAudioPlayer" style="width: 100%; height: 28px;" controls></audio>
|
||||||
|
<button id="audioUnlockBtn" type="button"
|
||||||
|
style="display: none; margin-top: 8px; width: 100%; padding: 6px 10px; font-size: 10px; letter-spacing: 1px; background: var(--accent-cyan); color: #000; border: 1px solid var(--accent-cyan); border-radius: 4px; cursor: pointer;">
|
||||||
|
CLICK TO ENABLE AUDIO
|
||||||
|
</button>
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||||
<span style="font-size: 7px; color: var(--text-muted);">LEVEL</span>
|
<span style="font-size: 7px; color: var(--text-muted);">LEVEL</span>
|
||||||
<div
|
<div
|
||||||
@@ -1165,6 +1071,13 @@
|
|||||||
style="font-size: 8px; color: var(--text-muted); min-width: 40px; text-align: right;">--
|
style="font-size: 8px; color: var(--text-muted); min-width: 40px; text-align: right;">--
|
||||||
dB</span>
|
dB</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||||
|
<span style="font-size: 7px; color: var(--text-muted); letter-spacing: 1px;">SNR THRESH</span>
|
||||||
|
<input type="range" id="snrThresholdSlider" min="6" max="20" step="1" value="8"
|
||||||
|
style="flex: 1;" />
|
||||||
|
<span id="snrThresholdValue"
|
||||||
|
style="font-size: 8px; color: var(--text-muted); min-width: 26px; text-align: right;">8</span>
|
||||||
|
</div>
|
||||||
<!-- Signal Alert inline -->
|
<!-- Signal Alert inline -->
|
||||||
<div id="mainSignalAlert"
|
<div id="mainSignalAlert"
|
||||||
style="display: none; background: rgba(0, 255, 100, 0.2); border: 1px solid var(--accent-green); border-radius: 4px; padding: 5px; text-align: center; margin-top: 8px;">
|
style="display: none; background: rgba(0, 255, 100, 0.2); border: 1px solid var(--accent-green); border-radius: 4px; padding: 5px; text-align: center; margin-top: 8px;">
|
||||||
@@ -1259,10 +1172,10 @@
|
|||||||
<!-- SQL, Gain, Vol Knobs -->
|
<!-- SQL, Gain, Vol Knobs -->
|
||||||
<div style="display: flex; gap: 15px;">
|
<div style="display: flex; gap: 15px;">
|
||||||
<div class="knob-container">
|
<div class="knob-container">
|
||||||
<div class="radio-knob" id="radioSquelchKnob" data-value="30" data-min="0"
|
<div class="radio-knob" id="radioSquelchKnob" data-value="0" data-min="0"
|
||||||
data-max="100" data-step="1"></div>
|
data-max="100" data-step="1"></div>
|
||||||
<div class="knob-label">SQL</div>
|
<div class="knob-label">SQL</div>
|
||||||
<div class="knob-value" id="radioSquelchValue">30</div>
|
<div class="knob-value" id="radioSquelchValue">0</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="knob-container">
|
<div class="knob-container">
|
||||||
<div class="radio-knob" id="radioGainKnob" data-value="40" data-min="0"
|
<div class="radio-knob" id="radioGainKnob" data-value="40" data-min="0"
|
||||||
@@ -1335,7 +1248,7 @@
|
|||||||
START</div>
|
START</div>
|
||||||
<input type="number" id="radioScanStart" value="118" step="0.1"
|
<input type="number" id="radioScanStart" value="118" step="0.1"
|
||||||
class="radio-input"
|
class="radio-input"
|
||||||
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-weight: bold; color: var(--accent-cyan);">
|
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: var(--font-mono); font-weight: bold; color: var(--accent-cyan);">
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
style="color: var(--text-muted); font-size: 16px; padding-top: 12px;">→</span>
|
style="color: var(--text-muted); font-size: 16px; padding-top: 12px;">→</span>
|
||||||
@@ -1344,15 +1257,17 @@
|
|||||||
END</div>
|
END</div>
|
||||||
<input type="number" id="radioScanEnd" value="137" step="0.1"
|
<input type="number" id="radioScanEnd" value="137" step="0.1"
|
||||||
class="radio-input"
|
class="radio-input"
|
||||||
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-weight: bold; color: var(--accent-cyan);">
|
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: var(--font-mono); font-weight: bold; color: var(--accent-cyan);">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<button class="radio-action-btn scan" id="radioScanBtn" onclick="toggleScanner()"
|
<button class="radio-action-btn scan" id="radioScanBtn" onclick="toggleScanner()">
|
||||||
style="padding: 12px; font-size: 13px; width: 100%; font-weight: bold;"><span class="icon icon--sm" style="margin-right: 4px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>SCAN</button>
|
<span class="icon icon--sm" style="margin-right: 4px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>SCAN
|
||||||
<button class="radio-action-btn" id="radioListenBtn" onclick="toggleDirectListen()"
|
</button>
|
||||||
style="padding: 10px; font-size: 12px; width: 100%; background: var(--accent-green); border: none; color: #fff;"><span class="icon icon--sm" style="margin-right: 4px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg></span>LISTEN</button>
|
<button class="radio-action-btn listen" id="radioListenBtn" onclick="toggleDirectListen()">
|
||||||
|
<span class="icon icon--sm" style="margin-right: 4px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg></span>LISTEN
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1410,19 +1325,19 @@
|
|||||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
<span style="font-size: 9px; color: var(--text-muted);">SIGNALS</span>
|
<span style="font-size: 9px; color: var(--text-muted);">SIGNALS</span>
|
||||||
<span
|
<span
|
||||||
style="color: var(--accent-green); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;"
|
style="color: var(--accent-green); font-size: 18px; font-weight: bold; font-family: var(--font-mono);"
|
||||||
id="mainSignalCount">0</span>
|
id="mainSignalCount">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
<span style="font-size: 9px; color: var(--text-muted);">SCANNED</span>
|
<span style="font-size: 9px; color: var(--text-muted);">SCANNED</span>
|
||||||
<span
|
<span
|
||||||
style="color: var(--accent-cyan); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;"
|
style="color: var(--accent-cyan); font-size: 18px; font-weight: bold; font-family: var(--font-mono);"
|
||||||
id="mainFreqsScanned">0</span>
|
id="mainFreqsScanned">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
<span style="font-size: 9px; color: var(--text-muted);">CYCLES</span>
|
<span style="font-size: 9px; color: var(--text-muted);">CYCLES</span>
|
||||||
<span
|
<span
|
||||||
style="color: var(--accent-orange); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;"
|
style="color: var(--accent-orange); font-size: 18px; font-weight: bold; font-family: var(--font-mono);"
|
||||||
id="mainScanCycles">0</span>
|
id="mainScanCycles">0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2117,7 +2032,7 @@
|
|||||||
// After fade out, hide welcome and switch to mode
|
// After fade out, hide welcome and switch to mode
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
welcome.style.display = 'none';
|
welcome.style.display = 'none';
|
||||||
switchMode(mode);
|
switchMode(mode, { updateUrl: true });
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2126,6 +2041,35 @@
|
|||||||
document.getElementById('disclaimerModal').style.display = 'flex';
|
document.getElementById('disclaimerModal').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mode from query string (e.g., /?mode=wifi)
|
||||||
|
let pendingStartMode = null;
|
||||||
|
const validModes = new Set([
|
||||||
|
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
||||||
|
'spystations', 'meshtastic', 'wifi', 'bluetooth',
|
||||||
|
'tscm', 'satellite', 'sstv'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function getModeFromQuery() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const mode = params.get('mode');
|
||||||
|
if (!mode || !validModes.has(mode)) return null;
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyModeFromQuery() {
|
||||||
|
const mode = getModeFromQuery();
|
||||||
|
if (!mode) return;
|
||||||
|
const accepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
||||||
|
if (accepted) {
|
||||||
|
const welcome = document.getElementById('welcomePage');
|
||||||
|
if (welcome) welcome.style.display = 'none';
|
||||||
|
switchMode(mode, { updateUrl: false });
|
||||||
|
updateModeUrl(mode, true);
|
||||||
|
} else {
|
||||||
|
pendingStartMode = mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function acceptDisclaimer() {
|
function acceptDisclaimer() {
|
||||||
localStorage.setItem('disclaimerAccepted', 'true');
|
localStorage.setItem('disclaimerAccepted', 'true');
|
||||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||||
@@ -2137,7 +2081,14 @@
|
|||||||
const gateStyle = document.getElementById('disclaimer-gate');
|
const gateStyle = document.getElementById('disclaimer-gate');
|
||||||
if (gateStyle) gateStyle.remove();
|
if (gateStyle) gateStyle.remove();
|
||||||
// Ensure welcome page is visible
|
// Ensure welcome page is visible
|
||||||
document.getElementById('welcomePage').style.display = '';
|
const welcome = document.getElementById('welcomePage');
|
||||||
|
if (welcome) welcome.style.display = '';
|
||||||
|
if (pendingStartMode) {
|
||||||
|
// Bypass welcome and jump to requested mode
|
||||||
|
welcome.style.display = 'none';
|
||||||
|
switchMode(pendingStartMode, { updateUrl: true });
|
||||||
|
pendingStartMode = null;
|
||||||
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2456,6 +2407,9 @@
|
|||||||
|
|
||||||
// Start SDR device status polling
|
// Start SDR device status polling
|
||||||
startSdrStatusPolling();
|
startSdrStatusPolling();
|
||||||
|
|
||||||
|
// Apply mode from URL query (e.g., /?mode=wifi)
|
||||||
|
applyModeFromQuery();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle section collapse
|
// Toggle section collapse
|
||||||
@@ -2511,8 +2465,20 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function updateModeUrl(mode, replace = false) {
|
||||||
|
if (!validModes.has(mode)) return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('mode', mode);
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState({ mode }, '', url);
|
||||||
|
} else {
|
||||||
|
window.history.pushState({ mode }, '', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mode switching
|
// Mode switching
|
||||||
function switchMode(mode) {
|
function switchMode(mode, options = {}) {
|
||||||
|
const { updateUrl = true } = options;
|
||||||
// Only stop local scans if in local mode (not agent mode)
|
// Only stop local scans if in local mode (not agent mode)
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
if (!isAgentMode) {
|
if (!isAgentMode) {
|
||||||
@@ -2525,6 +2491,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
|
if (updateUrl) {
|
||||||
|
updateModeUrl(mode);
|
||||||
|
}
|
||||||
|
|
||||||
// Sync mode state with current agent/local after switching
|
// Sync mode state with current agent/local after switching
|
||||||
if (isAgentMode && typeof syncAgentModeStates === 'function') {
|
if (isAgentMode && typeof syncAgentModeStates === 'function') {
|
||||||
@@ -2757,6 +2726,13 @@
|
|||||||
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
|
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('popstate', function () {
|
||||||
|
const mode = getModeFromQuery();
|
||||||
|
if (mode && mode !== currentMode) {
|
||||||
|
switchMode(mode, { updateUrl: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Also handle orientation changes explicitly for mobile
|
// Also handle orientation changes explicitly for mobile
|
||||||
window.addEventListener('orientationchange', function () {
|
window.addEventListener('orientationchange', function () {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -12205,7 +12181,7 @@
|
|||||||
<textarea id="tleInput" placeholder="ISS (ZARYA)
|
<textarea id="tleInput" placeholder="ISS (ZARYA)
|
||||||
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||||
2 25544 51.6400 208.9163 0006703 296.5855 63.4606 15.49995465478450"
|
2 25544 51.6400 208.9163 0006703 296.5855 63.4606 15.49995465478450"
|
||||||
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: 'JetBrains Mono', monospace; font-size: 11px; resize: vertical;"></textarea>
|
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;"></textarea>
|
||||||
<button class="preset-btn" onclick="addFromTLE()" style="margin-top: 10px; width: 100%;">Add
|
<button class="preset-btn" onclick="addFromTLE()" style="margin-top: 10px; width: 100%;">Add
|
||||||
Satellite</button>
|
Satellite</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="{{ theme|default('dark') }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}iNTERCEPT{% endblock %} // iNTERCEPT</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
|
|
||||||
|
{# Fonts - Conditional CDN/Local loading #}
|
||||||
|
{% if offline_settings and offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Core CSS (Design System) #}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/base.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
|
||||||
|
|
||||||
|
{# Responsive styles #}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
|
||||||
|
{# Page-specific CSS #}
|
||||||
|
{% block styles %}{% endblock %}
|
||||||
|
|
||||||
|
{# Page-specific head content #}
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
{# Global Header #}
|
||||||
|
{% block header %}
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="app-header-left">
|
||||||
|
<button class="hamburger-btn" id="hamburgerBtn" aria-label="Toggle navigation menu">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
|
<a href="/" class="app-logo">
|
||||||
|
<svg class="app-logo-icon" width="40" height="40" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 30 Q5 50, 15 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M22 35 Q14 50, 22 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M29 40 Q23 50, 29 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M85 30 Q95 50, 85 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M78 35 Q86 50, 78 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M71 40 Q77 50, 71 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<circle cx="50" cy="22" r="6" fill="var(--accent-green)"/>
|
||||||
|
<rect x="44" y="35" width="12" height="45" rx="2" fill="var(--accent-cyan)"/>
|
||||||
|
<rect x="38" y="35" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
|
||||||
|
<rect x="38" y="76" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
|
||||||
|
</svg>
|
||||||
|
<span class="app-logo-text">
|
||||||
|
<span class="app-logo-title">iNTERCEPT</span>
|
||||||
|
<span class="app-logo-tagline">// See the Invisible</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% if version %}
|
||||||
|
<span class="badge badge-primary">v{{ version }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="app-header-right">
|
||||||
|
{% block header_right %}
|
||||||
|
<div class="header-clock">
|
||||||
|
<span class="header-clock-label">UTC</span>
|
||||||
|
<span id="headerUtcTime">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{# Global Navigation - opt-in for pages that need it #}
|
||||||
|
{# Override this block and include 'partials/nav.html' in child templates #}
|
||||||
|
{% block navigation %}{% endblock %}
|
||||||
|
|
||||||
|
{# Main Content Area #}
|
||||||
|
<main class="app-main">
|
||||||
|
{% block main %}
|
||||||
|
<div class="content-wrapper">
|
||||||
|
{# Optional Sidebar #}
|
||||||
|
{% block sidebar %}{% endblock %}
|
||||||
|
|
||||||
|
{# Page Content #}
|
||||||
|
<div class="app-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{# Toast/Notification Container #}
|
||||||
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Core JavaScript #}
|
||||||
|
<script>
|
||||||
|
// UTC Clock
|
||||||
|
function updateUtcClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const utc = now.toISOString().slice(11, 19);
|
||||||
|
const clockEl = document.getElementById('headerUtcTime');
|
||||||
|
if (clockEl) clockEl.textContent = utc;
|
||||||
|
}
|
||||||
|
setInterval(updateUtcClock, 1000);
|
||||||
|
updateUtcClock();
|
||||||
|
|
||||||
|
// Mobile menu toggle
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||||
|
const drawerOverlay = document.getElementById('drawerOverlay');
|
||||||
|
|
||||||
|
if (hamburgerBtn) {
|
||||||
|
hamburgerBtn.addEventListener('click', function() {
|
||||||
|
this.classList.toggle('open');
|
||||||
|
document.querySelector('.app-sidebar')?.classList.toggle('open');
|
||||||
|
drawerOverlay?.classList.toggle('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawerOverlay) {
|
||||||
|
drawerOverlay.addEventListener('click', function() {
|
||||||
|
hamburgerBtn?.classList.remove('open');
|
||||||
|
document.querySelector('.app-sidebar')?.classList.remove('open');
|
||||||
|
this.classList.remove('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme toggle
|
||||||
|
function toggleTheme() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const currentTheme = html.getAttribute('data-theme') || 'dark';
|
||||||
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
html.setAttribute('data-theme', newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply saved theme
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nav dropdown handling
|
||||||
|
function toggleNavDropdown(groupName) {
|
||||||
|
const group = document.querySelector(`.nav-group[data-group="${groupName}"]`);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
// Close other dropdowns
|
||||||
|
document.querySelectorAll('.nav-group.open').forEach(g => {
|
||||||
|
if (g !== group) g.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
group.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('.nav-group')) {
|
||||||
|
document.querySelectorAll('.nav-group.open').forEach(g => g.classList.remove('open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{# Page-specific JavaScript #}
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
Dashboard Base Template
|
||||||
|
Extended layout for full-screen dashboard pages (ADSB, AIS, Satellite, etc.)
|
||||||
|
Features: Full-height layout, stats strip, sidebar overlay on mobile
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- active_mode: The current mode for nav highlighting (e.g., 'adsb', 'ais', 'satellite')
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{{ super() }}
|
||||||
|
<style>
|
||||||
|
/* Dashboard-specific overrides */
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radar/Grid background effect */
|
||||||
|
.dashboard-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at center, transparent 0%, var(--bg-primary) 70%),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 50px, var(--border-color) 50px, var(--border-color) 51px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 50px, var(--border-color) 50px, var(--border-color) 51px);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--border-color) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--border-color) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanline {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
opacity: 0.5;
|
||||||
|
animation: scanline 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scanline {
|
||||||
|
0% { top: 0; }
|
||||||
|
100% { top: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations toggle */
|
||||||
|
[data-animations="off"] .scanline,
|
||||||
|
[data-animations="off"] .radar-bg,
|
||||||
|
[data-animations="off"] .grid-bg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard main content */
|
||||||
|
.dashboard-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-fixed);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<header class="app-header" style="padding: 0 var(--space-3); height: 48px;">
|
||||||
|
<div class="app-header-left" style="gap: var(--space-3);">
|
||||||
|
<a href="/" class="app-logo" style="gap: var(--space-2);">
|
||||||
|
<svg class="app-logo-icon" width="28" height="28" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 30 Q5 50, 15 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M22 35 Q14 50, 22 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M29 40 Q23 50, 29 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M85 30 Q95 50, 85 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M78 35 Q86 50, 78 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M71 40 Q77 50, 71 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<circle cx="50" cy="22" r="6" fill="var(--accent-green)"/>
|
||||||
|
<rect x="44" y="35" width="12" height="45" rx="2" fill="var(--accent-cyan)"/>
|
||||||
|
<rect x="38" y="35" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
|
||||||
|
<rect x="38" y="76" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="dashboard-header-title">
|
||||||
|
<span style="font-size: var(--text-lg); font-weight: var(--font-bold); color: var(--text-primary);">
|
||||||
|
{% block dashboard_title %}DASHBOARD{% endblock %}
|
||||||
|
</span>
|
||||||
|
<span style="font-size: var(--text-sm); color: var(--text-dim); margin-left: var(--space-2);">
|
||||||
|
// iNTERCEPT
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-header-right">
|
||||||
|
{% block dashboard_header_center %}{% endblock %}
|
||||||
|
<div class="header-utilities" style="gap: var(--space-2);">
|
||||||
|
{% block agent_selector %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{# Include the unified nav partial with active_mode set #}
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{# Background effects #}
|
||||||
|
<div class="dashboard-bg">
|
||||||
|
{% block dashboard_bg %}
|
||||||
|
<div class="radar-bg"></div>
|
||||||
|
{% endblock %}
|
||||||
|
<div class="scanline"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Stats strip #}
|
||||||
|
{% block stats_strip %}{% endblock %}
|
||||||
|
|
||||||
|
{# Dashboard content #}
|
||||||
|
<div class="dashboard-content">
|
||||||
|
{% block dashboard_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
// Dashboard-specific scripts
|
||||||
|
(function() {
|
||||||
|
// Mobile sidebar toggle
|
||||||
|
const sidebarToggle = document.getElementById('sidebarToggle');
|
||||||
|
const sidebar = document.querySelector('.dashboard-sidebar');
|
||||||
|
const overlay = document.getElementById('drawerOverlay');
|
||||||
|
|
||||||
|
if (sidebarToggle && sidebar) {
|
||||||
|
sidebarToggle.addEventListener('click', function() {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
if (overlay) overlay.classList.toggle('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.addEventListener('click', function() {
|
||||||
|
sidebar?.classList.remove('open');
|
||||||
|
this.classList.remove('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC Clock update
|
||||||
|
function updateUtcClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const utc = now.toISOString().slice(11, 19) + ' UTC';
|
||||||
|
document.querySelectorAll('[id$="utcTime"], [id$="UtcTime"]').forEach(el => {
|
||||||
|
el.textContent = utc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(updateUtcClock, 1000);
|
||||||
|
updateUtcClock();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,13 +4,20 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Network Monitor // INTERCEPT</title>
|
<title>Network Monitor // INTERCEPT</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'JetBrains Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-primary: #0a0a0f;
|
--bg-primary: #0a0a0f;
|
||||||
--bg-secondary: #12121a;
|
--bg-secondary: #12121a;
|
||||||
--bg-tertiary: #1a1a2e;
|
--bg-tertiary: #1a1a2e;
|
||||||
@@ -25,7 +32,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -110,7 +117,7 @@
|
|||||||
|
|
||||||
.status-value {
|
.status-value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-grid {
|
.main-grid {
|
||||||
@@ -153,7 +160,7 @@
|
|||||||
background: rgba(0, 212, 255, 0.2);
|
background: rgba(0, 212, 255, 0.2);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs {
|
.panel-tabs {
|
||||||
@@ -223,7 +230,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-badges {
|
.source-badges {
|
||||||
@@ -241,7 +248,7 @@
|
|||||||
background: rgba(0, 212, 255, 0.15);
|
background: rgba(0, 212, 255, 0.15);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-badge .dot {
|
.source-badge .dot {
|
||||||
@@ -361,7 +368,7 @@
|
|||||||
.agent-url {
|
.agent-url {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -378,7 +385,7 @@
|
|||||||
|
|
||||||
.agent-stat-value {
|
.agent-stat-value {
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Event log */
|
/* Event log */
|
||||||
@@ -397,7 +404,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-entry {
|
.event-entry {
|
||||||
@@ -458,13 +465,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.location-coords {
|
.location-coords {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-accuracy {
|
.location-accuracy {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-badge {
|
.location-badge {
|
||||||
@@ -513,13 +520,10 @@
|
|||||||
NETWORK MONITOR
|
NETWORK MONITOR
|
||||||
<span>// MULTI-AGENT VIEW</span>
|
<span>// MULTI-AGENT VIEW</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="header-nav">
|
|
||||||
<a href="#" onclick="history.back(); return false;">Back</a>
|
|
||||||
<a href="/">Dashboard</a>
|
|
||||||
<a href="/controller/manage">Manage Agents</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<div class="status-item">
|
<div class="status-item">
|
||||||
<div class="status-dot" id="streamStatus"></div>
|
<div class="status-dot" id="streamStatus"></div>
|
||||||
@@ -1102,5 +1106,6 @@
|
|||||||
connectStream();
|
connectStream();
|
||||||
addLogEntry('system', 'Network Monitor initialized');
|
addLogEntry('system', 'Network Monitor initialized');
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
|
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
|
||||||
<span id="lpQuickFreq" style="font-size: 14px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary);">---.--- MHz</span>
|
<span id="lpQuickFreq" style="font-size: 14px; font-family: var(--font-mono); color: var(--text-primary);">---.--- MHz</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
|
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
{#
|
||||||
|
Global Navigation Partial
|
||||||
|
Single source of truth for app navigation
|
||||||
|
|
||||||
|
Compatible with:
|
||||||
|
- index.html (uses switchMode() for mode panels)
|
||||||
|
- Dashboard pages (uses navigation links)
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- active_mode: Current active mode (e.g., 'pager', 'adsb', 'wifi')
|
||||||
|
- is_index_page: If true, Satellite/SSTV use switchMode (panel mode)
|
||||||
|
If false (default), Satellite links to dashboard
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set is_index_page = is_index_page|default(false) %}
|
||||||
|
|
||||||
|
{% macro mode_item(mode, label, icon_svg, href=None) -%}
|
||||||
|
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
||||||
|
{%- if href %}
|
||||||
|
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
|
||||||
|
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||||
|
<span class="nav-label">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{%- elif is_index_page %}
|
||||||
|
<button class="mode-nav-btn {{ is_active }}" onclick="switchMode('{{ mode }}')">
|
||||||
|
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||||
|
<span class="nav-label">{{ label }}</span>
|
||||||
|
</button>
|
||||||
|
{%- else %}
|
||||||
|
<a href="/?mode={{ mode }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
|
||||||
|
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||||
|
<span class="nav-label">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{%- endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro mobile_item(mode, label, icon_svg, href=None) -%}
|
||||||
|
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
||||||
|
{%- if href %}
|
||||||
|
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
|
||||||
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
|
</a>
|
||||||
|
{%- elif is_index_page %}
|
||||||
|
<button class="mobile-nav-btn {{ is_active }}" data-mode="{{ mode }}" onclick="switchMode('{{ mode }}')">
|
||||||
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
|
</button>
|
||||||
|
{%- else %}
|
||||||
|
<a href="/?mode={{ mode }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
|
||||||
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
|
</a>
|
||||||
|
{%- endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# Desktop Navigation - uses existing CSS class names for compatibility #}
|
||||||
|
<nav class="mode-nav" id="mainNav">
|
||||||
|
{# SDR / RF Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="sdr">
|
||||||
|
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('sdr')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>
|
||||||
|
<span class="nav-label">SDR / RF</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
|
{{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
|
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
|
{{ mode_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||||
|
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||||
|
{{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||||
|
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||||
|
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
|
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Wireless Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="wireless">
|
||||||
|
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||||||
|
<span class="nav-label">Wireless</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg>') }}
|
||||||
|
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Security Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="security">
|
||||||
|
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('security')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||||
|
<span class="nav-label">Security</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Space Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="space">
|
||||||
|
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
|
||||||
|
<span class="nav-label">Space</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{% if is_index_page %}
|
||||||
|
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>') }}
|
||||||
|
{% else %}
|
||||||
|
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
|
||||||
|
{% endif %}
|
||||||
|
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Dynamic dashboard button (shown when in satellite mode) #}
|
||||||
|
<div class="mode-nav-actions">
|
||||||
|
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span>
|
||||||
|
<span class="nav-label">Full Dashboard</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Nav Utilities (clock, theme, tools) #}
|
||||||
|
<div class="nav-utilities">
|
||||||
|
<div class="nav-clock">
|
||||||
|
<span class="utc-label">UTC</span>
|
||||||
|
<span class="utc-time" id="headerUtcTime">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-divider"></div>
|
||||||
|
<div class="nav-tools">
|
||||||
|
<button class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations">
|
||||||
|
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
|
||||||
|
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
|
||||||
|
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
|
||||||
|
<span class="icon-sun icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<a href="/controller/monitor" class="nav-tool-btn" title="Network Monitor - Multi-Agent View" style="text-decoration: none;">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
|
||||||
|
</a>
|
||||||
|
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
|
||||||
|
</a>
|
||||||
|
<button class="nav-tool-btn" onclick="showSettings()" title="Settings">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
|
||||||
|
<button class="nav-tool-btn" onclick="logout(event)" title="Logout">
|
||||||
|
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# Mobile Navigation Bar #}
|
||||||
|
<nav class="mobile-nav-bar" id="mobileNavBar">
|
||||||
|
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
|
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
|
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
|
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||||
|
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||||
|
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||||
|
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
|
||||||
|
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||||
|
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
|
{% if is_index_page %}
|
||||||
|
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
||||||
|
{% else %}
|
||||||
|
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>', '/satellite/dashboard') }}
|
||||||
|
{% endif %}
|
||||||
|
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||||
|
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||||
|
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
|
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||||
|
<script>
|
||||||
|
// Ensure navigation functions exist (for dashboard pages that don't have the full JS)
|
||||||
|
if (typeof switchMode === 'undefined') {
|
||||||
|
window.switchMode = function(mode) {
|
||||||
|
// On dashboard pages, navigate to main page with mode param
|
||||||
|
window.location.href = '/?mode=' + mode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof toggleNavDropdown === 'undefined') {
|
||||||
|
window.toggleNavDropdown = function(groupName) {
|
||||||
|
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${groupName}"]`);
|
||||||
|
if (!dropdown) return;
|
||||||
|
|
||||||
|
// Close other dropdowns
|
||||||
|
document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => {
|
||||||
|
if (d !== dropdown) d.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.classList.toggle('open');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('.mode-nav-dropdown')) {
|
||||||
|
document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => d.classList.remove('open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof toggleAnimations === 'undefined') {
|
||||||
|
window.toggleAnimations = function() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const current = html.getAttribute('data-animations') || 'on';
|
||||||
|
const next = current === 'on' ? 'off' : 'on';
|
||||||
|
html.setAttribute('data-animations', next);
|
||||||
|
localStorage.setItem('animations', next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof toggleTheme === 'undefined') {
|
||||||
|
window.toggleTheme = function() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const current = html.getAttribute('data-theme') || 'dark';
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
html.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof showSettings === 'undefined') {
|
||||||
|
window.showSettings = function() {
|
||||||
|
// Navigate to main page settings
|
||||||
|
window.location.href = '/?settings=1';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof showHelp === 'undefined') {
|
||||||
|
window.showHelp = function() {
|
||||||
|
window.open('https://smittix.github.io/intercept', '_blank');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof logout === 'undefined') {
|
||||||
|
window.logout = function(e) {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
if (confirm('Are you sure you want to logout?')) {
|
||||||
|
window.location.href = '/logout';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply saved preferences and start clock
|
||||||
|
(function() {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedAnimations = localStorage.getItem('animations');
|
||||||
|
if (savedAnimations) {
|
||||||
|
document.documentElement.setAttribute('data-animations', savedAnimations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC Clock update (if not already defined by parent page)
|
||||||
|
if (typeof window._navClockStarted === 'undefined') {
|
||||||
|
window._navClockStarted = true;
|
||||||
|
function updateNavUtcClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const utc = now.toISOString().slice(11, 19);
|
||||||
|
const el = document.getElementById('headerUtcTime');
|
||||||
|
if (el) el.textContent = utc;
|
||||||
|
}
|
||||||
|
setInterval(updateNavUtcClock, 1000);
|
||||||
|
updateNavUtcClock();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{#
|
||||||
|
Page Header Partial
|
||||||
|
Consistent page title with optional description and actions
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- title: Page title (required)
|
||||||
|
- description: Optional description text
|
||||||
|
- back_url: Optional back link URL
|
||||||
|
- back_text: Optional back link text (default: "Back")
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
{% if back_url %}
|
||||||
|
<a href="{{ back_url }}" class="back-link mb-4">
|
||||||
|
<span class="icon icon--sm">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{{ back_text|default('Back') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">{{ title }}</h1>
|
||||||
|
{% if description %}
|
||||||
|
<p class="page-description">{{ description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if caller is defined %}
|
||||||
|
<div class="page-actions">
|
||||||
|
{{ caller() }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div class="settings-label">
|
<div class="settings-label">
|
||||||
<span class="settings-label-text">Web Fonts</span>
|
<span class="settings-label-text">Web Fonts</span>
|
||||||
<span class="settings-label-desc">Inter, JetBrains Mono</span>
|
<span class="settings-label-desc">JetBrains Mono</span>
|
||||||
</div>
|
</div>
|
||||||
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
|
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
|
||||||
<option value="cdn">Google Fonts (Online)</option>
|
<option value="cdn">Google Fonts (Online)</option>
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">Current Location</div>
|
<div class="settings-group-title">Current Location</div>
|
||||||
<div id="currentLocationDisplay" style="padding: 12px; background: var(--bg-tertiary); border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-size: 12px;">
|
<div id="currentLocationDisplay" style="padding: 12px; background: var(--bg-tertiary); border-radius: 6px; font-family: var(--font-mono); font-size: 12px;">
|
||||||
<div style="display: flex; justify-content: space-between; margin-bottom: 6px;">
|
<div style="display: flex; justify-content: space-between; margin-bottom: 6px;">
|
||||||
<span style="color: var(--text-dim);">Latitude</span>
|
<span style="color: var(--text-dim);">Latitude</span>
|
||||||
<span id="currentLatDisplay" style="color: var(--accent-cyan);">Not set</span>
|
<span id="currentLatDisplay" style="color: var(--accent-cyan);">Not set</span>
|
||||||
@@ -315,4 +315,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
||||||
<script>
|
<script>
|
||||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||||
@@ -70,6 +71,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% set active_mode = 'satellite' %}
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
|
||||||
<main class="dashboard">
|
<main class="dashboard">
|
||||||
<!-- Polar Plot -->
|
<!-- Polar Plot -->
|
||||||
<div class="panel polar-container">
|
<div class="panel polar-container">
|
||||||
@@ -217,7 +221,7 @@
|
|||||||
.location-label {
|
.location-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary, #8899aa);
|
color: var(--text-secondary, #8899aa);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.location-select {
|
.location-select {
|
||||||
background: rgba(0, 40, 60, 0.8);
|
background: rgba(0, 40, 60, 0.8);
|
||||||
@@ -226,7 +230,7 @@
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
@@ -1079,5 +1083,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user