mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
1422 lines
52 KiB
JavaScript
1422 lines
52 KiB
JavaScript
/**
|
|
* Settings Manager - Handles offline mode and application settings
|
|
*/
|
|
|
|
const Settings = {
|
|
// Default settings
|
|
defaults: {
|
|
'offline.enabled': false,
|
|
'offline.assets_source': 'local',
|
|
'offline.fonts_source': 'local',
|
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
|
'offline.tile_server_url': ''
|
|
},
|
|
|
|
// Tile provider configurations
|
|
tileProviders: {
|
|
openstreetmap: {
|
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
subdomains: 'abc'
|
|
},
|
|
cartodb_dark: {
|
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
subdomains: 'abcd',
|
|
mapTheme: 'cyber',
|
|
options: {}
|
|
},
|
|
cartodb_dark_cyan: {
|
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
subdomains: 'abcd',
|
|
mapTheme: 'cyber',
|
|
options: {}
|
|
},
|
|
cartodb_light: {
|
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
subdomains: 'abcd'
|
|
},
|
|
esri_world: {
|
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
|
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
|
subdomains: null
|
|
}
|
|
},
|
|
|
|
// Registry of maps that can be updated
|
|
_registeredMaps: [],
|
|
|
|
// Current settings cache
|
|
_cache: {},
|
|
|
|
// Init guard to prevent concurrent fetch races across pages/modes
|
|
_initialized: false,
|
|
_initPromise: null,
|
|
_themeObserver: null,
|
|
_themeObserverStarted: false,
|
|
_themeObserverRaf: null,
|
|
|
|
/**
|
|
* Check if a tile provider key is valid.
|
|
* @param {string} provider
|
|
* @returns {boolean}
|
|
*/
|
|
_isKnownTileProvider(provider) {
|
|
if (typeof provider !== 'string') return false;
|
|
const key = provider.trim();
|
|
return key === 'custom' || Object.prototype.hasOwnProperty.call(this.tileProviders, key);
|
|
},
|
|
|
|
/**
|
|
* Normalize tile provider values from storage/UI.
|
|
* @param {string} provider
|
|
* @returns {string}
|
|
*/
|
|
_normalizeTileProvider(provider) {
|
|
if (typeof provider !== 'string') return this.defaults['offline.tile_provider'];
|
|
const key = provider.trim();
|
|
if (this._isKnownTileProvider(key)) return key;
|
|
return this.defaults['offline.tile_provider'];
|
|
},
|
|
|
|
/**
|
|
* Persist and retrieve preferred map theme behavior for dark Carto tiles.
|
|
* Helps keep Cyber style enabled even if server-side tile provider drifts.
|
|
*/
|
|
_getMapThemePreference() {
|
|
if (typeof localStorage === 'undefined') return 'cyber';
|
|
const pref = localStorage.getItem('intercept_map_theme_pref');
|
|
if (pref === 'none' || pref === 'cyber') return pref;
|
|
return 'cyber';
|
|
},
|
|
|
|
_setMapThemePreference(pref) {
|
|
if (typeof localStorage === 'undefined') return;
|
|
if (pref !== 'none' && pref !== 'cyber') return;
|
|
localStorage.setItem('intercept_map_theme_pref', pref);
|
|
},
|
|
|
|
/**
|
|
* Toggle root class used for hard global Leaflet theming.
|
|
* @param {Object} [config]
|
|
*/
|
|
_syncRootMapThemeClass(config) {
|
|
if (typeof document === 'undefined' || !document.documentElement) return;
|
|
const resolvedConfig = config || this.getTileConfig();
|
|
const themeClass = this._getMapThemeClass(resolvedConfig);
|
|
document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber');
|
|
},
|
|
|
|
/**
|
|
* Prefer localStorage tile settings when available to avoid stale server values.
|
|
*/
|
|
_applyLocalTileOverrides() {
|
|
const stored = localStorage.getItem('intercept_settings');
|
|
if (!stored) return;
|
|
|
|
try {
|
|
const local = JSON.parse(stored) || {};
|
|
const localProvider = this._normalizeTileProvider(local['offline.tile_provider']);
|
|
if (localProvider) {
|
|
this._cache['offline.tile_provider'] = localProvider;
|
|
}
|
|
if (typeof local['offline.tile_server_url'] === 'string') {
|
|
this._cache['offline.tile_server_url'] = local['offline.tile_server_url'];
|
|
}
|
|
} catch (e) {
|
|
// Ignore malformed local settings and keep current cache.
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Initialize settings - load from server/localStorage
|
|
*/
|
|
async init(options = {}) {
|
|
const force = Boolean(options && options.force);
|
|
|
|
if (!force && this._initialized) {
|
|
return this._cache;
|
|
}
|
|
|
|
if (!force && this._initPromise) {
|
|
return this._initPromise;
|
|
}
|
|
|
|
this._initPromise = (async () => {
|
|
try {
|
|
const response = await fetch('/offline/settings');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this._cache = { ...this.defaults, ...data.settings };
|
|
} else {
|
|
// Fall back to localStorage
|
|
this._loadFromLocalStorage();
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to load settings from server, using localStorage:', e);
|
|
this._loadFromLocalStorage();
|
|
}
|
|
|
|
this._applyLocalTileOverrides();
|
|
this._cache['offline.tile_provider'] = this._normalizeTileProvider(this._cache['offline.tile_provider']);
|
|
|
|
// If dark Carto was restored by stale server settings but user prefers Cyber,
|
|
// keep the visible provider aligned with Cyber selection.
|
|
if (this._cache['offline.tile_provider'] === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
|
this._cache['offline.tile_provider'] = 'cartodb_dark_cyan';
|
|
}
|
|
this._updateUI();
|
|
|
|
// Re-apply map theme to already-registered maps in case init happened after map creation.
|
|
const allMaps = this._collectMaps();
|
|
if (allMaps.length > 0) {
|
|
const config = this.getTileConfig();
|
|
allMaps.forEach((map) => this._applyMapTheme(map, config));
|
|
}
|
|
const activeConfig = this.getTileConfig();
|
|
this._syncRootMapThemeClass(activeConfig);
|
|
this._applyThemeToAllContainers(activeConfig);
|
|
this._ensureThemeObserver();
|
|
|
|
this._initialized = true;
|
|
return this._cache;
|
|
})();
|
|
|
|
try {
|
|
return await this._initPromise;
|
|
} finally {
|
|
this._initPromise = null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Load settings from localStorage
|
|
*/
|
|
_loadFromLocalStorage() {
|
|
const stored = localStorage.getItem('intercept_settings');
|
|
if (stored) {
|
|
try {
|
|
this._cache = { ...this.defaults, ...JSON.parse(stored) };
|
|
} catch (e) {
|
|
this._cache = { ...this.defaults };
|
|
}
|
|
} else {
|
|
this._cache = { ...this.defaults };
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Save a setting to server and localStorage
|
|
*/
|
|
async _save(key, value) {
|
|
this._cache[key] = value;
|
|
|
|
// Save to localStorage as backup
|
|
localStorage.setItem('intercept_settings', JSON.stringify(this._cache));
|
|
|
|
// Save to server
|
|
try {
|
|
const response = await fetch('/offline/settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key, value })
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Save failed (${response.status})`);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to save setting to server:', e);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get a setting value
|
|
*/
|
|
get(key) {
|
|
return this._cache[key] ?? this.defaults[key];
|
|
},
|
|
|
|
/**
|
|
* Toggle offline mode master switch
|
|
*/
|
|
async toggleOfflineMode(enabled) {
|
|
await this._save('offline.enabled', enabled);
|
|
|
|
if (enabled) {
|
|
// When enabling offline mode, also switch assets and fonts to local
|
|
await this._save('offline.assets_source', 'local');
|
|
await this._save('offline.fonts_source', 'local');
|
|
}
|
|
|
|
this._updateUI();
|
|
this._showReloadPrompt();
|
|
},
|
|
|
|
/**
|
|
* Set asset source (cdn or local)
|
|
*/
|
|
async setAssetSource(source) {
|
|
await this._save('offline.assets_source', source);
|
|
this._showReloadPrompt();
|
|
},
|
|
|
|
/**
|
|
* Set fonts source (cdn or local)
|
|
*/
|
|
async setFontsSource(source) {
|
|
await this._save('offline.fonts_source', source);
|
|
this._showReloadPrompt();
|
|
},
|
|
|
|
/**
|
|
* Set tile provider
|
|
*/
|
|
async setTileProvider(provider) {
|
|
provider = this._normalizeTileProvider(provider);
|
|
|
|
if (provider === 'cartodb_dark_cyan') {
|
|
this._setMapThemePreference('cyber');
|
|
} else if (provider === 'cartodb_dark') {
|
|
this._setMapThemePreference('none');
|
|
} else {
|
|
this._setMapThemePreference('none');
|
|
}
|
|
|
|
await this._save('offline.tile_provider', provider);
|
|
|
|
// Show/hide custom URL input
|
|
const customRow = document.getElementById('customTileUrlRow');
|
|
if (customRow) {
|
|
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
|
}
|
|
|
|
// Update tiles immediately for all providers.
|
|
this._updateMapTiles();
|
|
const activeConfig = this.getTileConfig();
|
|
this._syncRootMapThemeClass(activeConfig);
|
|
this._applyThemeToAllContainers(activeConfig);
|
|
},
|
|
|
|
/**
|
|
* Set custom tile server URL
|
|
*/
|
|
async setCustomTileUrl(url) {
|
|
await this._save('offline.tile_server_url', url);
|
|
this._updateMapTiles();
|
|
},
|
|
|
|
/**
|
|
* Get current tile configuration
|
|
*/
|
|
getTileConfig() {
|
|
const provider = this._normalizeTileProvider(this.get('offline.tile_provider'));
|
|
|
|
if (provider === 'custom') {
|
|
const customUrl = this.get('offline.tile_server_url');
|
|
return {
|
|
url: customUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
attribution: 'Custom Tile Server',
|
|
subdomains: 'abc'
|
|
};
|
|
}
|
|
|
|
const config = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
|
|
|
// Robust fallback: if dark Carto is active and Cyber is preferred,
|
|
// keep Cyber theme enabled even when provider temporarily reverts.
|
|
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
|
return { ...config, mapTheme: 'cyber' };
|
|
}
|
|
|
|
return config;
|
|
},
|
|
|
|
/**
|
|
* Resolve map theme class from tile config.
|
|
* @param {Object} config
|
|
* @returns {string|null}
|
|
*/
|
|
_getMapThemeClass(config) {
|
|
if (!config || !config.mapTheme) return null;
|
|
if (config.mapTheme === 'cyber') return 'map-theme-cyber';
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Apply or clear map theme styles for a Leaflet container.
|
|
* @param {HTMLElement} container
|
|
* @param {Object} [config]
|
|
*/
|
|
_applyThemeToContainer(container, config) {
|
|
if (!container || !container.classList) return;
|
|
const tilePane = container.querySelector('.leaflet-tile-pane');
|
|
|
|
container.querySelectorAll('.intercept-map-theme-overlay').forEach((el) => el.remove());
|
|
|
|
if (tilePane && tilePane.style) {
|
|
tilePane.style.filter = '';
|
|
tilePane.style.opacity = '';
|
|
tilePane.style.willChange = '';
|
|
}
|
|
if (container.style) {
|
|
container.style.background = '';
|
|
}
|
|
|
|
container.classList.remove('map-theme-cyber');
|
|
|
|
const resolvedConfig = config || this.getTileConfig();
|
|
const themeClass = this._getMapThemeClass(resolvedConfig);
|
|
if (!themeClass) return;
|
|
|
|
container.classList.add(themeClass);
|
|
|
|
if (themeClass === 'map-theme-cyber') {
|
|
if (container.style) {
|
|
container.style.background = '#020813';
|
|
}
|
|
if (tilePane && tilePane.style) {
|
|
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
|
tilePane.style.opacity = '1';
|
|
tilePane.style.willChange = 'filter';
|
|
}
|
|
}
|
|
|
|
// Map overlays are rendered via CSS pseudo elements on
|
|
// `html.map-*-enabled .leaflet-container` for consistent stacking.
|
|
},
|
|
|
|
/**
|
|
* Apply/remove map theme class on a Leaflet map container.
|
|
* @param {L.Map} map
|
|
* @param {Object} [config]
|
|
*/
|
|
_applyMapTheme(map, config) {
|
|
if (!map || typeof map.getContainer !== 'function') return;
|
|
const container = map.getContainer();
|
|
this._applyThemeToContainer(container, config);
|
|
},
|
|
|
|
/**
|
|
* Apply current map theme to all rendered Leaflet containers.
|
|
* Covers maps that were not explicitly registered with Settings.
|
|
* @param {Object} [config]
|
|
*/
|
|
_applyThemeToAllContainers(config) {
|
|
if (typeof document === 'undefined') return;
|
|
const containers = document.querySelectorAll('.leaflet-container');
|
|
if (!containers.length) return;
|
|
|
|
const resolvedConfig = config || this.getTileConfig();
|
|
this._syncRootMapThemeClass(resolvedConfig);
|
|
containers.forEach((container) => this._applyThemeToContainer(container, resolvedConfig));
|
|
},
|
|
|
|
/**
|
|
* Watch the DOM for new Leaflet maps and apply current theme automatically.
|
|
*/
|
|
_ensureThemeObserver() {
|
|
if (this._themeObserverStarted || typeof MutationObserver === 'undefined') return;
|
|
if (typeof document === 'undefined' || !document.body) return;
|
|
|
|
const scheduleApply = () => {
|
|
if (this._themeObserverRaf && typeof cancelAnimationFrame === 'function') {
|
|
cancelAnimationFrame(this._themeObserverRaf);
|
|
}
|
|
if (typeof requestAnimationFrame === 'function') {
|
|
this._themeObserverRaf = requestAnimationFrame(() => {
|
|
this._themeObserverRaf = null;
|
|
this._applyThemeToAllContainers(this.getTileConfig());
|
|
});
|
|
} else {
|
|
this._applyThemeToAllContainers(this.getTileConfig());
|
|
}
|
|
};
|
|
|
|
this._themeObserver = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
if (!mutation.addedNodes || mutation.addedNodes.length === 0) continue;
|
|
for (const node of mutation.addedNodes) {
|
|
if (!(node instanceof Element)) continue;
|
|
if (node.classList.contains('leaflet-container') || node.querySelector('.leaflet-container')) {
|
|
scheduleApply();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
this._themeObserver.observe(document.body, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
|
|
this._themeObserverStarted = true;
|
|
},
|
|
|
|
/**
|
|
* Collect all known map instances.
|
|
* @returns {L.Map[]}
|
|
*/
|
|
_collectMaps() {
|
|
const windowMaps = [
|
|
window.map,
|
|
window.leafletMap,
|
|
window.aprsMap,
|
|
window.radarMap,
|
|
window.vesselMap,
|
|
window.groundMap,
|
|
window.groundTrackMap,
|
|
window.meshMap,
|
|
window.issMap
|
|
].filter(m => m && typeof m.eachLayer === 'function');
|
|
|
|
return [...new Set([...this._registeredMaps, ...windowMaps])];
|
|
},
|
|
|
|
/**
|
|
* Keep map theme stable if map internals or layers are refreshed.
|
|
* @param {L.Map} map - Leaflet map instance
|
|
*/
|
|
_attachMapThemeHooks(map) {
|
|
if (!map || typeof map.on !== 'function' || map._interceptThemeHookBound) return;
|
|
|
|
const reapplyTheme = () => this._applyMapTheme(map);
|
|
const hookEvents = ['layeradd', 'layerremove', 'zoomend', 'resize', 'load'];
|
|
hookEvents.forEach((eventName) => map.on(eventName, reapplyTheme));
|
|
|
|
map._interceptThemeHookBound = true;
|
|
map._interceptThemeHookHandler = reapplyTheme;
|
|
},
|
|
|
|
/**
|
|
* Register a map to receive tile updates when settings change
|
|
* @param {L.Map} map - Leaflet map instance
|
|
*/
|
|
registerMap(map) {
|
|
if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) {
|
|
this._registeredMaps.push(map);
|
|
}
|
|
this._ensureThemeObserver();
|
|
this._attachMapThemeHooks(map);
|
|
this._applyMapTheme(map);
|
|
this._applyThemeToAllContainers(this.getTileConfig());
|
|
|
|
// Some maps create tile DOM asynchronously; re-apply after first paint.
|
|
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
|
|
window.setTimeout(() => {
|
|
this._applyMapTheme(map);
|
|
this._applyThemeToAllContainers(this.getTileConfig());
|
|
}, 120);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Unregister a map
|
|
* @param {L.Map} map - Leaflet map instance
|
|
*/
|
|
unregisterMap(map) {
|
|
const idx = this._registeredMaps.indexOf(map);
|
|
if (idx > -1) {
|
|
this._registeredMaps.splice(idx, 1);
|
|
}
|
|
|
|
if (map && map._interceptThemeHookBound && typeof map.off === 'function') {
|
|
const handler = map._interceptThemeHookHandler;
|
|
['layeradd', 'layerremove', 'zoomend', 'resize', 'load'].forEach((eventName) => {
|
|
map.off(eventName, handler);
|
|
});
|
|
delete map._interceptThemeHookBound;
|
|
delete map._interceptThemeHookHandler;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Create a tile layer using current settings
|
|
* @returns {L.TileLayer} Configured tile layer
|
|
*/
|
|
createTileLayer() {
|
|
const config = this.getTileConfig();
|
|
const options = {
|
|
attribution: config.attribution,
|
|
maxZoom: 19,
|
|
...(config.options || {})
|
|
};
|
|
if (config.subdomains) {
|
|
options.subdomains = config.subdomains;
|
|
}
|
|
return L.tileLayer(config.url, options);
|
|
},
|
|
|
|
/**
|
|
* Check if local assets are available
|
|
*/
|
|
async checkAssets() {
|
|
const assets = {
|
|
leaflet: [
|
|
'/static/vendor/leaflet/leaflet.js',
|
|
'/static/vendor/leaflet/leaflet.css'
|
|
],
|
|
chartjs: [
|
|
'/static/vendor/chartjs/chart.umd.min.js'
|
|
],
|
|
inter: [
|
|
'/static/vendor/fonts/Inter-Regular.woff2'
|
|
],
|
|
jetbrains: [
|
|
'/static/vendor/fonts/JetBrainsMono-Regular.woff2'
|
|
]
|
|
};
|
|
|
|
const results = {};
|
|
|
|
for (const [name, urls] of Object.entries(assets)) {
|
|
const statusEl = document.getElementById(`status${name.charAt(0).toUpperCase() + name.slice(1)}`);
|
|
if (statusEl) {
|
|
statusEl.textContent = 'Checking...';
|
|
statusEl.className = 'asset-badge checking';
|
|
}
|
|
|
|
let available = true;
|
|
for (const url of urls) {
|
|
try {
|
|
const response = await fetch(url, { method: 'HEAD' });
|
|
if (!response.ok) {
|
|
available = false;
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
available = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
results[name] = available;
|
|
|
|
if (statusEl) {
|
|
statusEl.textContent = available ? 'Available' : 'Missing';
|
|
statusEl.className = `asset-badge ${available ? 'available' : 'missing'}`;
|
|
}
|
|
}
|
|
|
|
return results;
|
|
},
|
|
|
|
/**
|
|
* Update UI elements to reflect current settings
|
|
*/
|
|
_updateUI() {
|
|
// Offline mode toggle
|
|
const offlineEnabled = document.getElementById('offlineEnabled');
|
|
if (offlineEnabled) {
|
|
offlineEnabled.checked = this.get('offline.enabled');
|
|
}
|
|
|
|
// Assets source
|
|
const assetsSource = document.getElementById('assetsSource');
|
|
if (assetsSource) {
|
|
assetsSource.value = this.get('offline.assets_source');
|
|
}
|
|
|
|
// Fonts source
|
|
const fontsSource = document.getElementById('fontsSource');
|
|
if (fontsSource) {
|
|
fontsSource.value = this.get('offline.fonts_source');
|
|
}
|
|
|
|
// Tile provider
|
|
const tileProvider = document.getElementById('tileProvider');
|
|
if (tileProvider) {
|
|
tileProvider.value = this.get('offline.tile_provider');
|
|
}
|
|
|
|
// Custom tile URL
|
|
const customTileUrl = document.getElementById('customTileUrl');
|
|
if (customTileUrl) {
|
|
customTileUrl.value = this.get('offline.tile_server_url') || '';
|
|
}
|
|
|
|
// Show/hide custom URL row
|
|
const customRow = document.getElementById('customTileUrlRow');
|
|
if (customRow) {
|
|
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
|
}
|
|
|
|
// Theme select
|
|
const themeSelect = document.getElementById('themeSelect');
|
|
if (themeSelect) {
|
|
themeSelect.value = localStorage.getItem('intercept-theme') || 'dark';
|
|
}
|
|
|
|
// Animations toggle
|
|
const animationsEnabled = document.getElementById('animationsEnabled');
|
|
if (animationsEnabled) {
|
|
animationsEnabled.checked = localStorage.getItem('intercept-animations') !== 'off';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update map tiles on all known maps
|
|
*/
|
|
_updateMapTiles() {
|
|
const allMaps = this._collectMaps();
|
|
if (allMaps.length === 0) return;
|
|
|
|
const config = this.getTileConfig();
|
|
this._syncRootMapThemeClass(config);
|
|
|
|
allMaps.forEach(map => {
|
|
// Remove existing tile layers
|
|
map.eachLayer(layer => {
|
|
if (layer instanceof L.TileLayer) {
|
|
map.removeLayer(layer);
|
|
}
|
|
});
|
|
|
|
// Add new tile layer
|
|
const options = {
|
|
attribution: config.attribution,
|
|
maxZoom: 19,
|
|
...(config.options || {})
|
|
};
|
|
if (config.subdomains) {
|
|
options.subdomains = config.subdomains;
|
|
}
|
|
|
|
L.tileLayer(config.url, options).addTo(map);
|
|
this._applyMapTheme(map, config);
|
|
});
|
|
|
|
this._applyThemeToAllContainers(config);
|
|
},
|
|
|
|
/**
|
|
* Show reload prompt
|
|
*/
|
|
_showReloadPrompt() {
|
|
// Create or update reload prompt
|
|
let prompt = document.getElementById('settingsReloadPrompt');
|
|
if (!prompt) {
|
|
prompt = document.createElement('div');
|
|
prompt.id = 'settingsReloadPrompt';
|
|
prompt.style.cssText = `
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: var(--bg-dark, #0a0a0f);
|
|
border: 1px solid var(--accent-cyan, #00d4ff);
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
z-index: 10001;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
`;
|
|
prompt.innerHTML = `
|
|
<span style="color: var(--text-primary, #e0e0e0); font-size: 13px;">
|
|
Reload to apply changes
|
|
</span>
|
|
<button onclick="location.reload()" style="
|
|
background: var(--accent-cyan, #00d4ff);
|
|
border: none;
|
|
color: #000;
|
|
padding: 6px 12px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
">Reload</button>
|
|
<button onclick="this.parentElement.remove()" style="
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted, #666);
|
|
font-size: 18px;
|
|
cursor: pointer;
|
|
padding: 0 4px;
|
|
">×</button>
|
|
`;
|
|
document.body.appendChild(prompt);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Settings modal functions
|
|
let lastSettingsFocusEl = null;
|
|
|
|
function showSettings() {
|
|
const modal = document.getElementById('settingsModal');
|
|
if (modal) {
|
|
lastSettingsFocusEl = document.activeElement;
|
|
modal.classList.add('active');
|
|
modal.setAttribute('aria-hidden', 'false');
|
|
const content = modal.querySelector('.settings-content');
|
|
if (content) content.focus();
|
|
Settings.init().then(() => {
|
|
Settings.checkAssets();
|
|
});
|
|
}
|
|
}
|
|
|
|
function hideSettings() {
|
|
const modal = document.getElementById('settingsModal');
|
|
if (modal) {
|
|
modal.classList.remove('active');
|
|
modal.setAttribute('aria-hidden', 'true');
|
|
if (lastSettingsFocusEl && typeof lastSettingsFocusEl.focus === 'function') {
|
|
lastSettingsFocusEl.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
function switchSettingsTab(tabName) {
|
|
// Update tab buttons
|
|
document.querySelectorAll('.settings-tab').forEach(tab => {
|
|
const isActive = tab.dataset.tab === tabName;
|
|
tab.classList.toggle('active', isActive);
|
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
|
});
|
|
|
|
// Update sections
|
|
document.querySelectorAll('.settings-section').forEach(section => {
|
|
const isActive = section.id === `settings-${tabName}`;
|
|
section.classList.toggle('active', isActive);
|
|
section.hidden = !isActive;
|
|
section.setAttribute('role', 'tabpanel');
|
|
});
|
|
|
|
// Load tools/dependencies when that tab is selected
|
|
if (tabName === 'tools') {
|
|
loadSettingsTools();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load tool dependencies into settings modal
|
|
*/
|
|
function loadSettingsTools() {
|
|
const content = document.getElementById('settingsToolsContent');
|
|
if (!content) return;
|
|
|
|
content.innerHTML = '<div style="text-align: center; padding: 30px; color: var(--text-dim);">Loading dependencies...</div>';
|
|
|
|
fetch('/dependencies')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status !== 'success') {
|
|
content.innerHTML = '<div style="color: var(--accent-red);">Error loading dependencies</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
let totalMissing = 0;
|
|
|
|
for (const [modeKey, mode] of Object.entries(data.modes)) {
|
|
const statusColor = mode.ready ? 'var(--accent-green)' : 'var(--accent-red)';
|
|
const statusIcon = mode.ready ? '✓' : '✗';
|
|
|
|
html += `
|
|
<div style="background: var(--bg-tertiary); border-radius: 6px; padding: 12px; margin-bottom: 10px; border-left: 3px solid ${statusColor};">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
<span style="font-weight: 600; color: var(--accent-cyan); font-size: 13px;">${mode.name}</span>
|
|
<span style="color: ${statusColor}; font-size: 11px; font-weight: bold;">${statusIcon} ${mode.ready ? 'Ready' : 'Missing'}</span>
|
|
</div>
|
|
<div style="display: grid; gap: 6px;">
|
|
`;
|
|
|
|
for (const [toolName, tool] of Object.entries(mode.tools)) {
|
|
const installed = tool.installed;
|
|
const dotColor = installed ? 'var(--accent-green)' : 'var(--accent-red)';
|
|
const requiredBadge = tool.required ? '<span style="background: var(--accent-orange); color: #000; padding: 1px 4px; border-radius: 3px; font-size: 9px; margin-left: 4px;">REQ</span>' : '';
|
|
|
|
if (!installed) totalMissing++;
|
|
|
|
let installCmd = '';
|
|
if (tool.install) {
|
|
if (tool.install.pip) {
|
|
installCmd = tool.install.pip;
|
|
} else if (data.pkg_manager && tool.install[data.pkg_manager]) {
|
|
installCmd = tool.install[data.pkg_manager];
|
|
} else if (tool.install.manual) {
|
|
installCmd = tool.install.manual;
|
|
}
|
|
}
|
|
|
|
html += `
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--bg-secondary); border-radius: 4px; font-size: 11px;">
|
|
<span style="color: ${dotColor}; font-size: 12px;">●</span>
|
|
<div style="flex: 1; min-width: 0;">
|
|
<span style="font-weight: 500;">${toolName}${requiredBadge}</span>
|
|
<div style="font-size: 10px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${tool.description}</div>
|
|
</div>
|
|
${!installed && installCmd ? `
|
|
<code style="font-size: 9px; background: var(--bg-tertiary); padding: 2px 6px; border-radius: 3px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${installCmd}">${installCmd}</code>
|
|
` : ''}
|
|
<span style="font-size: 10px; color: ${dotColor}; font-weight: bold; min-width: 45px; text-align: right;">${installed ? 'OK' : 'MISSING'}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
html += '</div></div>';
|
|
}
|
|
|
|
// Summary at top
|
|
const summaryHtml = `
|
|
<div style="background: ${totalMissing > 0 ? 'rgba(255, 100, 0, 0.1)' : 'rgba(0, 255, 100, 0.1)'}; border: 1px solid ${totalMissing > 0 ? 'var(--accent-orange)' : 'var(--accent-green)'}; border-radius: 6px; padding: 10px 12px; margin-bottom: 12px;">
|
|
<div style="font-size: 13px; font-weight: bold; color: ${totalMissing > 0 ? 'var(--accent-orange)' : 'var(--accent-green)'};">
|
|
${totalMissing > 0 ? '⚠️ ' + totalMissing + ' tool(s) not found' : '✓ All tools installed'}
|
|
</div>
|
|
<div style="font-size: 11px; color: var(--text-dim); margin-top: 3px;">
|
|
OS: ${data.os} | Package Manager: ${data.pkg_manager}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
content.innerHTML = summaryHtml + html;
|
|
})
|
|
.catch(err => {
|
|
content.innerHTML = '<div style="color: var(--accent-red);">Error loading dependencies: ' + err.message + '</div>';
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Location Settings Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Load and display current observer location
|
|
*/
|
|
function loadObserverLocation() {
|
|
let lat = localStorage.getItem('observerLat');
|
|
let lon = localStorage.getItem('observerLon');
|
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
|
const shared = ObserverLocation.getShared();
|
|
lat = shared.lat.toString();
|
|
lon = shared.lon.toString();
|
|
}
|
|
|
|
const latInput = document.getElementById('observerLatInput');
|
|
const lonInput = document.getElementById('observerLonInput');
|
|
const currentLatDisplay = document.getElementById('currentLatDisplay');
|
|
const currentLonDisplay = document.getElementById('currentLonDisplay');
|
|
|
|
if (latInput && lat) latInput.value = lat;
|
|
if (lonInput && lon) lonInput.value = lon;
|
|
|
|
if (currentLatDisplay) {
|
|
currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
|
|
}
|
|
if (currentLonDisplay) {
|
|
currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
|
|
}
|
|
|
|
// Sync dashboard-specific location keys for backward compatibility
|
|
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') {
|
|
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
|
if (!localStorage.getItem('observerLocation')) {
|
|
localStorage.setItem('observerLocation', locationObj);
|
|
}
|
|
if (!localStorage.getItem('ais_observerLocation')) {
|
|
localStorage.setItem('ais_observerLocation', locationObj);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect location using gpsd (USB GPS) or browser geolocation as fallback
|
|
*/
|
|
function detectLocationGPS(btn) {
|
|
const latInput = document.getElementById('observerLatInput');
|
|
const lonInput = document.getElementById('observerLonInput');
|
|
|
|
// Show loading state with visual feedback
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = '<span class="detecting-spinner"></span> Detecting...';
|
|
btn.disabled = true;
|
|
btn.style.opacity = '0.7';
|
|
|
|
// Helper to restore button state
|
|
function restoreButton() {
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
btn.style.opacity = '';
|
|
}
|
|
|
|
// Helper to set location values
|
|
function setLocation(lat, lon, source) {
|
|
if (latInput) latInput.value = parseFloat(lat).toFixed(4);
|
|
if (lonInput) lonInput.value = parseFloat(lon).toFixed(4);
|
|
restoreButton();
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Location', `Coordinates set from ${source}`);
|
|
}
|
|
}
|
|
|
|
// First, try gpsd (USB GPS device)
|
|
fetch('/gps/position')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'ok' && data.position && data.position.latitude != null) {
|
|
// Got valid position from gpsd
|
|
setLocation(data.position.latitude, data.position.longitude, 'GPS device');
|
|
} else if (data.status === 'waiting') {
|
|
// gpsd connected but no fix yet - show message and try browser
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('GPS', 'GPS device connected but no fix yet. Trying browser location...');
|
|
}
|
|
useBrowserGeolocation();
|
|
} else {
|
|
// gpsd not available, try browser geolocation
|
|
useBrowserGeolocation();
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// gpsd request failed, try browser geolocation
|
|
useBrowserGeolocation();
|
|
});
|
|
|
|
// Fallback to browser geolocation
|
|
function useBrowserGeolocation() {
|
|
if (!navigator.geolocation) {
|
|
restoreButton();
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Location', 'No GPS available (gpsd not running, browser GPS unavailable)');
|
|
} else {
|
|
alert('No GPS available');
|
|
}
|
|
return;
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
setLocation(pos.coords.latitude, pos.coords.longitude, 'browser');
|
|
},
|
|
(err) => {
|
|
restoreButton();
|
|
let message = 'Failed to get location';
|
|
if (err.code === 1) message = 'Location access denied';
|
|
else if (err.code === 2) message = 'Location unavailable';
|
|
else if (err.code === 3) message = 'Location request timed out';
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Location', message);
|
|
} else {
|
|
alert(message);
|
|
}
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 10000 }
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save observer location to localStorage
|
|
*/
|
|
function saveObserverLocation() {
|
|
const latInput = document.getElementById('observerLatInput');
|
|
const lonInput = document.getElementById('observerLonInput');
|
|
|
|
const lat = parseFloat(latInput?.value);
|
|
const lon = parseFloat(lonInput?.value);
|
|
|
|
if (isNaN(lat) || lat < -90 || lat > 90) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Location', 'Invalid latitude (must be -90 to 90)');
|
|
} else {
|
|
alert('Invalid latitude (must be -90 to 90)');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isNaN(lon) || lon < -180 || lon > 180) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Location', 'Invalid longitude (must be -180 to 180)');
|
|
} else {
|
|
alert('Invalid longitude (must be -180 to 180)');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
|
ObserverLocation.setShared({ lat, lon });
|
|
} else {
|
|
localStorage.setItem('observerLat', lat.toString());
|
|
localStorage.setItem('observerLon', lon.toString());
|
|
}
|
|
|
|
// Also update dashboard-specific location keys for ADS-B and AIS
|
|
const locationObj = JSON.stringify({ lat: lat, lon: lon });
|
|
localStorage.setItem('observerLocation', locationObj); // ADS-B dashboard
|
|
localStorage.setItem('ais_observerLocation', locationObj); // AIS dashboard
|
|
|
|
// Update display
|
|
const currentLatDisplay = document.getElementById('currentLatDisplay');
|
|
const currentLonDisplay = document.getElementById('currentLonDisplay');
|
|
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
|
|
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Location', 'Observer location saved');
|
|
}
|
|
|
|
if (window.observerLocation) {
|
|
window.observerLocation.lat = lat;
|
|
window.observerLocation.lon = lon;
|
|
}
|
|
|
|
// Refresh SSTV ISS schedule if available
|
|
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
|
|
SSTV.loadIssSchedule();
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Update Settings Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Check for updates manually from settings panel
|
|
*/
|
|
async function checkForUpdatesManual() {
|
|
const content = document.getElementById('updateStatusContent');
|
|
if (!content) return;
|
|
|
|
if (typeof Updater === 'undefined') {
|
|
content.innerHTML = `<div style="color: var(--text-dim); padding: 10px;">Update checking is unavailable. If you use a content blocker, try allowing <code>updater.js</code> to load.</div>`;
|
|
return;
|
|
}
|
|
|
|
content.innerHTML = '<div style="text-align: center; padding: 20px; color: var(--text-dim);">Checking for updates...</div>';
|
|
|
|
try {
|
|
const data = await Updater.checkNow();
|
|
renderUpdateStatus(data);
|
|
} catch (error) {
|
|
content.innerHTML = `<div style="color: var(--accent-red); padding: 10px;">Error checking for updates: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load update status when tab is opened
|
|
*/
|
|
async function loadUpdateStatus() {
|
|
const content = document.getElementById('updateStatusContent');
|
|
if (!content) return;
|
|
|
|
if (typeof Updater === 'undefined') {
|
|
content.innerHTML = `<div style="color: var(--text-dim); padding: 10px;">Update checking is unavailable. If you use a content blocker, try allowing <code>updater.js</code> to load.</div>`;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await Updater.getStatus();
|
|
renderUpdateStatus(data);
|
|
} catch (error) {
|
|
content.innerHTML = `<div style="color: var(--accent-red); padding: 10px;">Error loading update status: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render update status in settings panel
|
|
*/
|
|
function renderUpdateStatus(data) {
|
|
const content = document.getElementById('updateStatusContent');
|
|
if (!content) return;
|
|
|
|
if (!data.success) {
|
|
content.innerHTML = `<div style="color: var(--accent-red); padding: 10px;">Error: ${data.error || 'Unknown error'}</div>`;
|
|
return;
|
|
}
|
|
|
|
if (data.disabled) {
|
|
content.innerHTML = `
|
|
<div style="padding: 15px; background: var(--bg-tertiary); border-radius: 6px; text-align: center;">
|
|
<div style="color: var(--text-dim); font-size: 13px;">Update checking is disabled</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
if (!data.checked) {
|
|
content.innerHTML = `
|
|
<div style="padding: 15px; background: var(--bg-tertiary); border-radius: 6px; text-align: center;">
|
|
<div style="color: var(--text-dim); font-size: 13px;">No update check performed yet</div>
|
|
<div style="font-size: 11px; color: var(--text-dim); margin-top: 5px;">Click "Check Now" to check for updates</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const statusColor = data.update_available ? 'var(--accent-green)' : 'var(--text-dim)';
|
|
const statusText = data.update_available ? 'Update Available' : 'Up to Date';
|
|
const statusIcon = data.update_available
|
|
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>'
|
|
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
|
|
|
|
let html = `
|
|
<div style="padding: 15px; background: var(--bg-tertiary); border-radius: 6px; border-left: 3px solid ${statusColor};">
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
|
|
<span style="color: ${statusColor};">${statusIcon}</span>
|
|
<span style="font-weight: 600; color: ${statusColor};">${statusText}</span>
|
|
</div>
|
|
<div style="display: grid; gap: 8px; font-size: 12px;">
|
|
<div style="display: flex; justify-content: space-between;">
|
|
<span style="color: var(--text-dim);">Current Version</span>
|
|
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: var(--text-primary);">v${data.current_version}</span>
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between;">
|
|
<span style="color: var(--text-dim);">Latest Version</span>
|
|
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
|
|
</div>
|
|
${data.last_check ? `
|
|
<div style="display: flex; justify-content: space-between;">
|
|
<span style="color: var(--text-dim);">Last Checked</span>
|
|
<span style="color: var(--text-secondary);">${formatLastCheck(data.last_check)}</span>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
${data.update_available ? `
|
|
<button onclick="Updater.showUpdateModal()" style="
|
|
margin-top: 12px;
|
|
width: 100%;
|
|
padding: 8px;
|
|
background: var(--accent-green);
|
|
color: #000;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
">View Update Details</button>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
|
|
content.innerHTML = html;
|
|
}
|
|
|
|
/**
|
|
* Format last check timestamp
|
|
*/
|
|
function formatLastCheck(isoString) {
|
|
try {
|
|
const date = new Date(isoString);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
|
|
if (diffMins < 1) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins} min ago`;
|
|
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
|
return date.toLocaleDateString();
|
|
} catch (e) {
|
|
return isoString;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle update checking
|
|
*/
|
|
async function toggleUpdateCheck(enabled) {
|
|
// This would require adding a setting to disable update checks
|
|
// For now, just store in localStorage
|
|
localStorage.setItem('intercept_update_check_enabled', enabled ? 'true' : 'false');
|
|
|
|
if (!enabled && typeof Updater !== 'undefined') {
|
|
Updater.destroy();
|
|
} else if (enabled && typeof Updater !== 'undefined') {
|
|
Updater.init();
|
|
}
|
|
}
|
|
|
|
// Extend switchSettingsTab to load update status
|
|
const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? switchSettingsTab : null;
|
|
|
|
function switchSettingsTab(tabName) {
|
|
// Update tab buttons
|
|
document.querySelectorAll('.settings-tab').forEach(tab => {
|
|
const isActive = tab.dataset.tab === tabName;
|
|
tab.classList.toggle('active', isActive);
|
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
|
});
|
|
|
|
// Update sections
|
|
document.querySelectorAll('.settings-section').forEach(section => {
|
|
const isActive = section.id === `settings-${tabName}`;
|
|
section.classList.toggle('active', isActive);
|
|
section.hidden = !isActive;
|
|
section.setAttribute('role', 'tabpanel');
|
|
});
|
|
|
|
// Load content based on tab
|
|
if (tabName === 'tools') {
|
|
loadSettingsTools();
|
|
} else if (tabName === 'updates') {
|
|
loadUpdateStatus();
|
|
} else if (tabName === 'location') {
|
|
loadObserverLocation();
|
|
} else if (tabName === 'alerts') {
|
|
loadVoiceAlertConfig();
|
|
if (typeof AlertCenter !== 'undefined') {
|
|
AlertCenter.loadFeed();
|
|
}
|
|
} else if (tabName === 'recording') {
|
|
if (typeof RecordingUI !== 'undefined') {
|
|
RecordingUI.refresh();
|
|
}
|
|
} else if (tabName === 'apikeys') {
|
|
loadApiKeyStatus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load voice alert configuration into Settings > Alerts tab
|
|
*/
|
|
function loadVoiceAlertConfig() {
|
|
if (typeof VoiceAlerts === 'undefined') return;
|
|
const cfg = VoiceAlerts.getConfig();
|
|
|
|
const pager = document.getElementById('voiceCfgPager');
|
|
const tscm = document.getElementById('voiceCfgTscm');
|
|
const tracker = document.getElementById('voiceCfgTracker');
|
|
const military = document.getElementById('voiceCfgAdsbMilitary');
|
|
const squawk = document.getElementById('voiceCfgSquawk');
|
|
const rate = document.getElementById('voiceCfgRate');
|
|
const pitch = document.getElementById('voiceCfgPitch');
|
|
const rateVal = document.getElementById('voiceCfgRateVal');
|
|
const pitchVal = document.getElementById('voiceCfgPitchVal');
|
|
|
|
if (pager) pager.checked = cfg.streams.pager !== false;
|
|
if (tscm) tscm.checked = cfg.streams.tscm !== false;
|
|
if (tracker) tracker.checked = cfg.streams.bluetooth !== false;
|
|
if (military) military.checked = cfg.streams.adsb_military !== false;
|
|
if (squawk) squawk.checked = cfg.streams.squawks !== false;
|
|
if (rate) rate.value = cfg.rate;
|
|
if (pitch) pitch.value = cfg.pitch;
|
|
if (rateVal) rateVal.textContent = cfg.rate;
|
|
if (pitchVal) pitchVal.textContent = cfg.pitch;
|
|
|
|
// Populate voice dropdown
|
|
VoiceAlerts.getAvailableVoices().then(function (voices) {
|
|
var sel = document.getElementById('voiceCfgVoice');
|
|
if (!sel) return;
|
|
sel.innerHTML = '<option value="">Default</option>' +
|
|
voices.filter(function (v) { return v.lang.startsWith('en'); }).map(function (v) {
|
|
return '<option value="' + v.name + '"' + (v.name === cfg.voiceName ? ' selected' : '') + '>' + v.name + '</option>';
|
|
}).join('');
|
|
});
|
|
}
|
|
|
|
function saveVoiceAlertConfig() {
|
|
if (typeof VoiceAlerts === 'undefined') return;
|
|
VoiceAlerts.setConfig({
|
|
rate: parseFloat(document.getElementById('voiceCfgRate')?.value) || 1.1,
|
|
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
|
|
voiceName: document.getElementById('voiceCfgVoice')?.value || '',
|
|
streams: {
|
|
pager: !!document.getElementById('voiceCfgPager')?.checked,
|
|
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
|
|
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
|
|
adsb_military: !!document.getElementById('voiceCfgAdsbMilitary')?.checked,
|
|
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
|
|
},
|
|
});
|
|
}
|
|
|
|
function testVoiceAlert() {
|
|
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.testVoice();
|
|
}
|
|
|
|
/**
|
|
* Load API key status into the API Keys settings tab
|
|
*/
|
|
function loadApiKeyStatus() {
|
|
const badge = document.getElementById('apiKeyStatusBadge');
|
|
const desc = document.getElementById('apiKeyStatusDesc');
|
|
const usage = document.getElementById('apiKeyUsageCount');
|
|
const bar = document.getElementById('apiKeyUsageBar');
|
|
|
|
if (!badge) return;
|
|
|
|
badge.textContent = 'Not available';
|
|
badge.className = 'asset-badge missing';
|
|
desc.textContent = 'GSM feature removed';
|
|
}
|
|
|
|
/**
|
|
* Save API key from the settings input
|
|
*/
|
|
function saveApiKey() {
|
|
const input = document.getElementById('apiKeyInput');
|
|
const result = document.getElementById('apiKeySaveResult');
|
|
if (!input || !result) return;
|
|
|
|
const key = input.value.trim();
|
|
if (!key) {
|
|
result.style.display = 'block';
|
|
result.style.color = 'var(--accent-red)';
|
|
result.textContent = 'Please enter an API key.';
|
|
return;
|
|
}
|
|
|
|
result.style.display = 'block';
|
|
result.style.color = 'var(--text-dim)';
|
|
result.textContent = 'Saving...';
|
|
|
|
result.style.color = 'var(--accent-red)';
|
|
result.textContent = 'GSM feature has been removed.';
|
|
}
|
|
|
|
/**
|
|
* Toggle API key input visibility
|
|
*/
|
|
function toggleApiKeyVisibility() {
|
|
const input = document.getElementById('apiKeyInput');
|
|
if (!input) return;
|
|
input.type = input.type === 'password' ? 'text' : 'password';
|
|
}
|
|
|
|
/**
|
|
* Set theme preference from the Display settings tab
|
|
*/
|
|
function setThemePreference(value) {
|
|
document.documentElement.setAttribute('data-theme', value);
|
|
localStorage.setItem('intercept-theme', value);
|
|
|
|
const btn = document.getElementById('themeToggle');
|
|
if (btn) {
|
|
btn.textContent = value === 'light' ? '🌙' : '☀️';
|
|
}
|
|
|
|
fetch('/settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ theme: value })
|
|
}).catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Set animations preference from the Display settings tab
|
|
*/
|
|
function setAnimationsEnabled(enabled) {
|
|
if (enabled) {
|
|
document.documentElement.removeAttribute('data-animations');
|
|
} else {
|
|
document.documentElement.setAttribute('data-animations', 'off');
|
|
}
|
|
localStorage.setItem('intercept-animations', enabled ? 'on' : 'off');
|
|
}
|
|
|
|
if (!window._settingsEscapeHandlerBound) {
|
|
window._settingsEscapeHandlerBound = true;
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Escape') return;
|
|
const modal = document.getElementById('settingsModal');
|
|
if (modal && modal.classList.contains('active')) {
|
|
hideSettings();
|
|
}
|
|
});
|
|
}
|