diff --git a/package.json b/package.json index 06f3245..1669514 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fm-dx-webserver", - "version": "1.2.8.1", + "version": "1.3.0", "description": "FM DX Webserver", "main": "index.js", "scripts": { diff --git a/server/datahandler.js b/server/datahandler.js index a9c0855..4b0f16a 100644 --- a/server/datahandler.js +++ b/server/datahandler.js @@ -235,7 +235,7 @@ var dataToSend = { dist: '', azi: '', id: '', - reg: '', + reg: false, pi: '', }, country_name: '', @@ -394,21 +394,26 @@ function handleData(wss, receivedData, rdsWss) { } // Get the received TX info - const currentTx = fetchTx(parseFloat(dataToSend.freq).toFixed(1), dataToSend.pi, dataToSend.ps); - if(currentTx && currentTx.station !== undefined) { - dataToSend.txInfo = { - tx: currentTx.station, - pol: currentTx.pol, - erp: currentTx.erp, - city: currentTx.city, - itu: currentTx.itu, - dist: currentTx.distance, - azi: currentTx.azimuth, - id: currentTx.id, - pi: currentTx.pi, - reg: currentTx.reg - } - } + fetchTx(parseFloat(dataToSend.freq).toFixed(1), dataToSend.pi, dataToSend.ps) + .then((currentTx) => { + if (currentTx && currentTx.station !== undefined) { + dataToSend.txInfo = { + tx: currentTx.station, + pol: currentTx.pol, + erp: currentTx.erp, + city: currentTx.city, + itu: currentTx.itu, + dist: currentTx.distance, + azi: currentTx.azimuth, + id: currentTx.id, + pi: currentTx.pi, + reg: currentTx.reg + }; + } + }) + .catch((error) => { + logError("Error fetching Tx info:", error); + }); // Send the updated data to the client const dataToSendJSON = JSON.stringify(dataToSend); diff --git a/server/endpoints.js b/server/endpoints.js index 2d4adb4..c8a30da 100644 --- a/server/endpoints.js +++ b/server/endpoints.js @@ -62,6 +62,7 @@ router.get('/', (req, res) => { device: serverConfig.device, noPlugins, plugins: serverConfig.plugins, + fmlist_integration: serverConfig.fmlist_integration ? serverConfig.fmlist_integration : true, bwSwitch: serverConfig.bwSwitch ? serverConfig.bwSwitch : false }); } @@ -74,6 +75,11 @@ router.get('/403', (req, res) => { router.get('/wizard', (req, res) => { let serialPorts; + if(!req.session.isAdminAuthenticated) { + res.render('login'); + return; + } + SerialPort.list() .then((deviceList) => { serialPorts = deviceList.map(port => ({ @@ -94,6 +100,11 @@ router.get('/wizard', (req, res) => { router.get('/setup', (req, res) => { let serialPorts; + + if(!req.session.isAdminAuthenticated) { + res.render('login'); + return; + } SerialPort.list() .then((deviceList) => { @@ -260,6 +271,14 @@ router.get('/ping', (req, res) => { }); router.get('/log_fmlist', (req, res) => { + if(dataHandler.dataToSend.txInfo.tx.length === 0) { + res.status(500).send('No suitable transmitter to log.'); + return; + } + + if(serverConfig.extras?.fmlist_integration == false) { + res.status(500).send('FMLIST Integration is not enabled on this server.'); + } const clientIp = req.headers['x-forwarded-for'] || req.connection.remoteAddress; const postData = JSON.stringify({ station: { @@ -278,11 +297,12 @@ router.get('/log_fmlist', (req, res) => { longitude: serverConfig.identification.lon, address: serverConfig.identification.proxyIp.length > 1 ? serverConfig.identification.proxyIp : ('Matches request IP with port ' + serverConfig.webserver.port), webserver_name: serverConfig.identification.tunerName, + omid: serverConfig.extras?.fmlist_omid || '', }, client: { request_ip: clientIp }, - log_msg: `PS: ${dataHandler.dataToSend.ps}, PI: ${dataHandler.dataToSend.pi}, Signal: ${dataHandler.dataToSend.sig.toFixed(0)} dBf` + log_msg: `Logged PS: ${dataHandler.dataToSend.ps.replace(/\s+/g, '_')}, PI: ${dataHandler.dataToSend.pi}, Signal: ${dataHandler.dataToSend.sig.toFixed(0)} dBf` }); const options = { diff --git a/server/index.js b/server/index.js index fa1e542..fa0742d 100644 --- a/server/index.js +++ b/server/index.js @@ -52,7 +52,7 @@ function startPluginsWithDelay(plugins, delay) { setTimeout(() => { const pluginName = path.basename(pluginPath, '.js'); // Extract plugin name from path logInfo(`-----------------------------------------------------------------`); - logInfo(`Plugin ${pluginName} is loaded`); + logInfo(`Plugin ${pluginName} loaded successfully!`); require(pluginPath); }, delay * index); }); diff --git a/server/server_config.js b/server/server_config.js index 38d7a43..2553d94 100644 --- a/server/server_config.js +++ b/server/server_config.js @@ -45,6 +45,10 @@ let serverConfig = { tunePass: "", adminPass: "" }, + extras: { + fmlist_integration: true, + fmlist_omid: "", + }, plugins: [], device: 'tef', defaultFreq: 87.5, diff --git a/server/tx_search.js b/server/tx_search.js index c39c1aa..f44bbe9 100644 --- a/server/tx_search.js +++ b/server/tx_search.js @@ -3,29 +3,69 @@ const { serverConfig } = require('./server_config'); const consoleCmd = require('./console'); let cachedData = {}; - let lastFetchTime = 0; -const fetchInterval = 3000; - +const fetchInterval = 1000; const esSwitchCache = {"lastCheck":0, "esSwitch":false}; const esFetchInterval = 300000; +const usStatesGeoJsonUrl = "https://raw.githubusercontent.com/PublicaMundi/MappingAPI/master/data/geojson/us-states.json"; +let usStatesGeoJson = null; // To cache the GeoJSON data for US states + +// Load the US states GeoJSON data +async function loadUsStatesGeoJson() { + if (!usStatesGeoJson) { + const response = await fetch(usStatesGeoJsonUrl); + usStatesGeoJson = await response.json(); + } +} + +// Function to get bounding box of a state +function getStateBoundingBox(coordinates) { + let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity; + for (const polygon of coordinates) { + for (const coord of polygon[0]) { // First level in case of MultiPolygon + const [lon, lat] = coord; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + } + } + return { minLat, maxLat, minLon, maxLon }; +} + +// Function to check if a city (lat, lon) falls within the bounding box of a state +function isCityInState(lat, lon, boundingBox) { + return lat >= boundingBox.minLat && lat <= boundingBox.maxLat && + lon >= boundingBox.minLon && lon <= boundingBox.maxLon; +} + +// Function to check if a city (lat, lon) is inside any US state and return the state name +function getStateForCoordinates(lat, lon) { + if (!usStatesGeoJson) return null; + + for (const feature of usStatesGeoJson.features) { + const boundingBox = getStateBoundingBox(feature.geometry.coordinates); + if (isCityInState(lat, lon, boundingBox)) { + return feature.properties.name; // Return the state's name if city is inside bounding box + } + } + return null; +} // Fetch data from maps -function fetchTx(freq, piCode, rdsPs) { +async function fetchTx(freq, piCode, rdsPs) { const now = Date.now(); freq = parseFloat(freq); - if(isNaN(freq)) { + if (isNaN(freq)) { return; } - // Check if it's been at least 3 seconds since the last fetch and if the QTH is correct if (now - lastFetchTime < fetchInterval || serverConfig.identification.lat.length < 2 || freq < 87) { return Promise.resolve(); } lastFetchTime = now; - // Check if data for the given frequency is already cached if (cachedData[freq]) { return processData(cachedData[freq], piCode, rdsPs); } @@ -34,32 +74,32 @@ function fetchTx(freq, piCode, rdsPs) { return fetch(url) .then(response => response.json()) - .then(data => { - // Cache the fetched data for the specific frequency + .then(async (data) => { cachedData[freq] = data; + await loadUsStatesGeoJson(); return processData(data, piCode, rdsPs); }) .catch(error => { + console.error("Error fetching data:", error); }); } -function processData(data, piCode, rdsPs) { +async function processData(data, piCode, rdsPs) { let matchingStation = null; let matchingCity = null; - let maxScore = -Infinity; // Initialize maxScore with a very low value + let maxScore = -Infinity; let txAzimuth; let maxDistance; let esMode = checkEs(); - let detectedByPireg = false; // To track if the station was found by pireg + let detectedByPireg = false; - // Helper function to calculate score and update matching station/city function evaluateStation(station, city, distance) { let weightDistance = distance.distanceKm; if (esMode && distance.distanceKm > 500) { weightDistance = Math.abs(distance.distanceKm - 1500); } let erp = station.erp && station.erp > 0 ? station.erp : 1; - const score = (10 * Math.log10(erp * 1000)) / weightDistance; // Calculate score + const score = (10 * Math.log10(erp * 1000)) / weightDistance; if (score > maxScore) { maxScore = score; txAzimuth = distance.azimuth; @@ -77,13 +117,13 @@ function processData(data, piCode, rdsPs) { if (station.pi === piCode.toUpperCase() && !station.extra && station.ps && station.ps.toLowerCase().includes(rdsPs.replace(/ /g, '_').replace(/^_*(.*?)_*$/, '$1').toLowerCase())) { const distance = haversine(serverConfig.identification.lat, serverConfig.identification.lon, city.lat, city.lon); evaluateStation(station, city, distance); - detectedByPireg = false; // Detected by pi, not pireg + detectedByPireg = false; } } } } - // If no matching station is found, fallback to pireg + // Fallback to pireg if no match is found if (!matchingStation) { for (const cityId in data.locations) { const city = data.locations[cityId]; @@ -92,27 +132,34 @@ function processData(data, piCode, rdsPs) { if (station.pireg && station.pireg.toUpperCase() === piCode.toUpperCase() && !station.extra && station.ps && station.ps.toLowerCase().includes(rdsPs.replace(/ /g, '_').replace(/^_*(.*?)_*$/, '$1').toLowerCase())) { const distance = haversine(serverConfig.identification.lat, serverConfig.identification.lon, city.lat, city.lon); evaluateStation(station, city, distance); - detectedByPireg = true; // Detected by pireg + detectedByPireg = true; } } } } } - // Return the results if a station was found, otherwise return undefined + // Determine the state if the city is in the USA + if (matchingStation && matchingCity.itu === 'USA') { + const state = getStateForCoordinates(matchingCity.lat, matchingCity.lon); + if (state) { + matchingCity.state = state; // Add state to matchingCity + } + } + if (matchingStation) { return { station: matchingStation.station.replace("R.", "Radio "), pol: matchingStation.pol.toUpperCase(), erp: matchingStation.erp && matchingStation.erp > 0 ? matchingStation.erp : '?', city: matchingCity.name, - itu: matchingCity.itu, + itu: matchingCity.state ? matchingCity.state + ', ' + matchingCity.itu : matchingCity.itu, distance: maxDistance.toFixed(0), azimuth: txAzimuth.toFixed(0), id: matchingStation.id, pi: matchingStation.pi, foundStation: true, - reg: detectedByPireg // Indicates if it was detected by pireg + reg: detectedByPireg }; } else { return; @@ -151,26 +198,19 @@ function checkEs() { } function haversine(lat1, lon1, lat2, lon2) { - const R = 6371; // Earth radius in kilometers + const R = 6371; const dLat = deg2rad(lat2 - lat1); const dLon = deg2rad(lon2 - lon1); - const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - - // Distance in kilometers const distance = R * c; - // Azimuth calculation const y = Math.sin(dLon) * Math.cos(deg2rad(lat2)); const x = Math.cos(deg2rad(lat1)) * Math.sin(deg2rad(lat2)) - Math.sin(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.cos(dLon); const azimuth = Math.atan2(y, x); - - // Convert azimuth from radians to degrees const azimuthDegrees = (azimuth * 180 / Math.PI + 360) % 360; return { @@ -179,7 +219,6 @@ function haversine(lat1, lon1, lat2, lon2) { }; } - function deg2rad(deg) { return deg * (Math.PI / 180); } diff --git a/web/css/breadcrumbs.css b/web/css/breadcrumbs.css index 0338bec..d16da01 100644 --- a/web/css/breadcrumbs.css +++ b/web/css/breadcrumbs.css @@ -191,40 +191,22 @@ label { } .checkbox label { - position: relative; cursor: pointer; - display: flex; - align-items: center; - user-select: none; - } - - .checkbox label:before { - content:''; - appearance: none; - -webkit-appearance: none; - background-color: transparent; - border: 2px solid var(--color-4); - padding: 10px; - display: inline-block; - position: relative; - vertical-align: middle; - cursor: pointer; - margin-right: 5px; - } - - .form-group input:checked + label:before { - background-color: var(--color-4); - } - - .form-group input:checked + label:after { - content: '✓'; display: block; - position: absolute; - font-size: 18px; - top: -1px; - left: 5px; - width: 18px; - height: 18px; + user-select: none; + padding: 7px 20px; + border-radius: 15px; + text-align: center; + border: 2px solid var(--color-4); + box-sizing: border-box; + transition: 0.35s ease background-color, 0.35s ease color; + } + .checkbox label:hover { + background-color: var(--color-2); + } + + .form-group input:checked + label { + background-color: var(--color-4); color: var(--color-main); } diff --git a/web/css/buttons.css b/web/css/buttons.css index 506ab85..9948f05 100644 --- a/web/css/buttons.css +++ b/web/css/buttons.css @@ -18,6 +18,10 @@ button:hover { opacity: 0.6; } +.cursor-disabled { + cursor: not-allowed; +} + .btn-next { width: 200px; padding: 10px; diff --git a/web/css/helpers.css b/web/css/helpers.css index 049bbbc..c408a3e 100644 --- a/web/css/helpers.css +++ b/web/css/helpers.css @@ -190,6 +190,10 @@ padding: 10px; } +.p-20 { + padding: 20px; +} + .p-left-10 { padding-left: 10px; } diff --git a/web/css/main.css b/web/css/main.css index eb9e425..77501b1 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -73,7 +73,6 @@ body { .wrapper-outer-static { display: block !important; padding-top: 10px; - padding-bottom: 10px; } #wrapper { @@ -84,6 +83,8 @@ body { margin: auto; position: static; transform: none; + width: calc(100% - 420px); + margin-left: 420px; } a { @@ -141,4 +142,15 @@ hr { margin: 50px auto; width: 100%; } +} + +@media (max-width: 768px) { + #wrapper.setup-wrapper { + width: 100%; + margin-left: 0; + } + + .setup-wrapper h2 { + display: initial; + } } \ No newline at end of file diff --git a/web/css/panels.css b/web/css/panels.css index e5b4677..762bcb4 100644 --- a/web/css/panels.css +++ b/web/css/panels.css @@ -52,6 +52,19 @@ width: 98%; } +.panel-100-real { + width: 100%; +} + +.panel-full { + margin-left: 0; + margin-right: 0; + width: 100%; + max-width: 100% !important; + transition: 0.35s ease; + box-sizing: border-box; +} + @media only screen and (max-width: 768px) { .panel-75 { width: 90%; diff --git a/web/css/setup.css b/web/css/setup.css index 4fd140a..f080068 100644 --- a/web/css/setup.css +++ b/web/css/setup.css @@ -10,14 +10,13 @@ } .setup-wrapper h2 { - font-size: 32px; + font-size: 42px; font-weight: 300; - padding: 10px; - text-transform: uppercase; + padding: 20px 15px; + text-align: left; } - -.setup-wrapper textarea { +#wrapper textarea { width: 100%; max-width: 768px; background-color: var(--color-2); @@ -26,34 +25,92 @@ padding-top: 10px; } +.sidenav { + background-color: var(--color-main); + } + + .sidenav li a:focus { + outline: none; + } + + .sidenav-content { + flex: 1; + position: relative; + overflow-y: auto; + } + + .sidenav .closebtn { + position: absolute; + top: 0; + right: 25px; + font-size: 36px; + margin-left: 50px; + } + + .sidenav h1 { + font-size: 42px; + text-transform: initial; + font-weight: 300; + text-align: center; + } ul.nav { list-style-type: none; padding: 15px 0; - background: var(--color-2); border-radius: 15px; } ul.nav li { - display: inline; - padding: 15px; + padding: 12px 20px; cursor: pointer; transition: color 0.3s ease, background-color 0.3s ease; user-select: none; } -ul.nav li:hover { - color: var(--color-main); - background-color: var(--color-4); +ul.nav li a { + color: var(--color-5) !important; } -li.active { + +ul.nav li:hover { background-color: var(--color-3); } +ul.nav li:hover a { + color: var(--color-main) !important; +} + +ul.nav li.active a { + color: var(--color-main) !important; + font-weight: bold; +} + + +li.active { + background-color: var(--color-4); +} + .tab-content { display: none; } +#navigation { + position: fixed; + top: 0; + left: 0; + width: 420px; /* Width of the sidenav */ + height: 100%; + z-index: 1000; /* Ensure it's above other content */ + transition: margin-left 0.3s ease; /* Smooth transition */ + } + + .admin-wrapper { + transition: margin-left 0.3s ease, width 0.3s ease; + } + + .admin-wrapper > .panel-full > .panel-full { + min-height: 100vh; + } + #map { height:400px; width:100%; @@ -66,6 +123,12 @@ li.active { .setup-wrapper h3 { font-weight: 300; margin: 8px; + font-size: 36px; + color: var(--color-5) +} + +.setup-wrapper h4 { + color: var(--color-4); } @@ -87,18 +150,15 @@ li.active { } @media only screen and (max-width: 768px) { - ul.nav { - display: flex; - overflow-y: scroll; - background: transparent; - } - - ul.nav li { - background-color: var(--color-4); - color: var(--color-main); - margin: 0px 10px; - padding: 15px 35px; - border-radius: 15px; - min-width: fit-content; + .setup-wrapper .panel-33, .setup-wrapper .panel-50 { + background: var(--color-1-transparent); } + #navigation { + width: 100vw; /* You can make the sidenav full width on mobile if you want */ + } + + .admin-wrapper { + margin-left: 0; + width: 100%; + } } \ No newline at end of file diff --git a/web/css/toast.css b/web/css/toast.css index a3682e8..dd73970 100644 --- a/web/css/toast.css +++ b/web/css/toast.css @@ -1,4 +1,11 @@ /* Basic Toast Styling */ + #toast-container { + position: fixed; + top: 20px; + right: 96px; + z-index: 9999; + } + .toast { padding: 15px; margin-top: 10px; @@ -8,7 +15,7 @@ position: relative; transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease; transform: translateY(-10px); /* Initial animation state */ - backdrop-filter: blur(10px); + backdrop-filter: blur(50px); } .toast:hover { @@ -87,4 +94,14 @@ font-size: 16px; color: #fff; cursor: pointer; - } \ No newline at end of file + } + + @media only screen and (max-width: 768px) { + #toast-container { + left: 0; + right: 0; + } + .toast { + margin: auto; + } +} \ No newline at end of file diff --git a/web/index.ejs b/web/index.ejs index 43422b6..f808779 100644 --- a/web/index.ejs +++ b/web/index.ejs @@ -157,17 +157,11 @@