From bb50aff7b4730509ec792c592beccc79c53db04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Farka=C5=A1?= Date: Sun, 16 Feb 2025 13:26:35 +0100 Subject: [PATCH] css UI fixes, new panel, code optimizaiton, security fixes --- package-lock.json | 4 +- package.json | 2 +- server/datahandler.js | 2 +- server/helpers.js | 95 ++-- server/index.js | 268 +++++------ web/css/breadcrumbs.css | 166 +++---- web/css/helpers.css | 37 +- web/css/main.css | 22 +- web/css/modal.css | 7 +- web/css/panels.css | 22 +- web/css/toast.css | 2 +- web/index.ejs | 954 +++++++++++++++++++++------------------- web/js/chat.js | 4 +- web/js/init.js | 4 +- web/js/main.js | 672 +++++++++++++++------------- web/js/plugins.js | 61 +++ web/js/settings.js | 2 +- web/js/webserver.js | 3 +- web/login.ejs | 2 +- web/setup.ejs | 2 +- web/wizard.ejs | 2 +- 21 files changed, 1279 insertions(+), 1054 deletions(-) create mode 100644 web/js/plugins.js diff --git a/package-lock.json b/package-lock.json index fe42fd1..42f4dfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fm-dx-webserver", - "version": "1.3.4", + "version": "1.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fm-dx-webserver", - "version": "1.3.4", + "version": "1.3.5", "license": "ISC", "dependencies": { "@mapbox/node-pre-gyp": "1.0.11", diff --git a/package.json b/package.json index ecb234c..3354eae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fm-dx-webserver", - "version": "1.3.4", + "version": "1.3.5", "description": "FM DX Webserver", "main": "index.js", "scripts": { diff --git a/server/datahandler.js b/server/datahandler.js index 82c9e8a..7467ab6 100644 --- a/server/datahandler.js +++ b/server/datahandler.js @@ -417,7 +417,7 @@ function handleData(wss, receivedData, rdsWss) { } }) .catch((error) => { - logError("Error fetching Tx info:", error); + console.log("Error fetching Tx info:", error); }); // Send the updated data to the client diff --git a/server/helpers.js b/server/helpers.js index 4c955dd..23368c6 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -1,4 +1,5 @@ const http = require('http'); +const https = require('https'); const net = require('net'); const crypto = require('crypto'); const dataHandler = require('./datahandler'); @@ -56,44 +57,86 @@ function authenticateWithXdrd(client, salt, password) { client.write('x\n'); } -function handleConnect(clientIp, currentUsers, ws) { - http.get(`http://ip-api.com/json/${clientIp}`, (response) => { - let data = ''; +const ipCache = new Map(); - response.on('data', (chunk) => { +function handleConnect(clientIp, currentUsers, ws, callback) { + if (ipCache.has(clientIp)) { + // Use cached location info + processConnection(clientIp, ipCache.get(clientIp), currentUsers, ws, callback); + return; + } + + http.get(`http://ip-api.com/json/${clientIp}`, (response) => { + let data = ""; + + response.on("data", (chunk) => { data += chunk; }); - response.on('end', () => { + response.on("end", () => { try { const locationInfo = JSON.parse(data); - const options = { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }; - const connectionTime = new Date().toLocaleString([], options); - - if (locationInfo.as?.includes("AS205016 HERN Labs AB")) { // anti opera VPN block - return; - } - - if(locationInfo.country === undefined) { - const userData = { ip: clientIp, location: 'Unknown', time: connectionTime, instance: ws }; - storage.connectedUsers.push(userData); - consoleCmd.logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`); - } else { - const userLocation = `${locationInfo.city}, ${locationInfo.regionName}, ${locationInfo.countryCode}`; - const userData = { ip: clientIp, location: userLocation, time: connectionTime, instance: ws }; - storage.connectedUsers.push(userData); - consoleCmd.logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${locationInfo.city}, ${locationInfo.regionName}, ${locationInfo.country}`); - } + ipCache.set(clientIp, locationInfo); // Store in cache + processConnection(clientIp, locationInfo, currentUsers, ws, callback); } catch (error) { - console.log(error); - consoleCmd.logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`); + console.error("Error parsing location data:", error); + callback("User allowed"); } }); - }).on('error', (err) => { - consoleCmd.logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`); + }).on("error", (err) => { + console.error("Error fetching location data:", err); + callback("User allowed"); }); } +function processConnection(clientIp, locationInfo, currentUsers, ws, callback) { + const options = { year: "numeric", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }; + const connectionTime = new Date().toLocaleString([], options); + + https.get("https://fmdx.org/banned_as.json", (banResponse) => { + let banData = ""; + + banResponse.on("data", (chunk) => { + banData += chunk; + }); + + banResponse.on("end", () => { + try { + const bannedAS = JSON.parse(banData).banned_as || []; + + if (bannedAS.some((as) => locationInfo.as?.includes(as))) { + return callback("User banned"); + } + + const userLocation = + locationInfo.country === undefined + ? "Unknown" + : `${locationInfo.city}, ${locationInfo.regionName}, ${locationInfo.countryCode}`; + + storage.connectedUsers.push({ + ip: clientIp, + location: userLocation, + time: connectionTime, + instance: ws, + }); + + consoleCmd.logInfo( + `Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${userLocation}` + ); + + callback("User allowed"); + } catch (error) { + console.error("Error parsing banned AS list:", error); + callback("User allowed"); + } + }); + }).on("error", (err) => { + console.error("Error fetching banned AS list:", err); + callback("User allowed"); + }); +} + + function formatUptime(uptimeInSeconds) { const secondsInMinute = 60; const secondsInHour = secondsInMinute * 60; diff --git a/server/index.js b/server/index.js index ed74388..501c2b3 100644 --- a/server/index.js +++ b/server/index.js @@ -326,174 +326,144 @@ app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, '../web')); app.use('/', endpoints); -// Anti-spam function -function antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, lengthCommands, endpointName) { - const command = message.toString(); - const now = Date.now(); - if (endpointName === 'text') logDebug(`Command received from \x1b[90m${clientIp}\x1b[0m: ${command}`); - - // Initialize user command history if not present - if (!userCommandHistory[clientIp]) { - userCommandHistory[clientIp] = []; - } - - // Record the current timestamp for the user - userCommandHistory[clientIp].push(now); - - // Remove timestamps older than 20 ms from the history - userCommandHistory[clientIp] = userCommandHistory[clientIp].filter(timestamp => now - timestamp <= 20); - - // Check if there are 8 or more commands in the last 20 ms - if (userCommandHistory[clientIp].length >= 8) { - logWarn(`User \x1b[90m${clientIp}\x1b[0m is spamming with rapid commands. Connection will be terminated and user will be banned.`); - - // Add to banlist if not already banned - if (!serverConfig.webserver.banlist.includes(clientIp)) { - serverConfig.webserver.banlist.push(clientIp); - logInfo(`User \x1b[90m${clientIp}\x1b[0m has been added to the banlist due to extreme spam.`); - console.log(serverConfig.webserver.banlist); - configSave(); - } - - ws.close(1008, 'Bot-like behavior detected'); - return command; // Return command value before closing connection - } - - // Update the last message time for general spam detection - lastMessageTime = now; - - // Initialize command history for rate-limiting checks - if (!userCommands[command]) { - userCommands[command] = []; - } - - // Record the current timestamp for this command - userCommands[command].push(now); - - // Remove timestamps older than 1 second - userCommands[command] = userCommands[command].filter(timestamp => now - timestamp <= 1000); - - // If command count exceeds limit, close connection - if (userCommands[command].length > lengthCommands) { - if (now - lastWarn.time > 1000) { // Check if 1 second has passed - logWarn(`User \x1b[90m${clientIp}\x1b[0m is spamming command "${command}" in /${endpointName}. Connection will be terminated.`); - lastWarn.time = now; // Update the last warning time - } - ws.close(1008, 'Spamming detected'); - return command; // Return command value before closing connection - } - - return command; // Return command value for normal execution -} - /** * WEBSOCKET BLOCK */ +const tunerLockTracker = new WeakMap(); + wss.on('connection', (ws, request) => { - const output = serverConfig.xdrd.wirelessConnection ? client : serialport; - let clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; - const userCommandHistory = {}; - if (serverConfig.webserver.banlist?.includes(clientIp)) { - ws.close(1008, 'Banned IP'); - return; - } + const output = serverConfig.xdrd.wirelessConnection ? client : serialport; + let clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; + const userCommandHistory = {}; - if (clientIp.includes(',')) { - clientIp = clientIp.split(',')[0].trim(); - } - - if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.0.1') || (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { - currentUsers++; - } - - dataHandler.showOnlineUsers(currentUsers); - - if(currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) { - serverConfig.xdrd.wirelessConnection === true ? connectToXdrd() : serialport.write('x\n'); - } - - helpers.handleConnect(clientIp, currentUsers, ws); - - // Anti-spam tracking for each client - const userCommands = {}; - let lastWarn = { time: 0 }; - - ws.on('message', (message) => { - const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text'); - - if (((command.startsWith('X') || command.startsWith('Y')) && !request.session.isAdminAuthenticated) || - ((command.startsWith('F') || command.startsWith('W')) && serverConfig.bwSwitch === false)) { - logWarn(`User \x1b[90m${clientIp}\x1b[0m attempted to send a potentially dangerous command. You may consider blocking this user.`); + if (serverConfig.webserver.banlist?.includes(clientIp)) { + ws.close(1008, 'Banned IP'); return; } - if (command.includes("\'")) { - return; + if (clientIp.includes(',')) { + clientIp = clientIp.split(',')[0].trim(); } - if (command.startsWith('w') && request.session.isAdminAuthenticated) { - switch (command) { - case 'wL1': serverConfig.lockToAdmin = true; break; - case 'wL0': serverConfig.lockToAdmin = false; break; - case 'wT0': serverConfig.publicTuner = true; break; - case 'wT1': serverConfig.publicTuner = false; break; - default: break; - } + if (clientIp !== '::ffff:127.0.0.1' || + (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.1') || + (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { + currentUsers++; } - if (command.startsWith('T')) { - const tuneFreq = Number(command.slice(1)) / 1000; - const { tuningLimit, tuningLowerLimit, tuningUpperLimit } = serverConfig.webserver; - - if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) { - return; - } - } + helpers.handleConnect(clientIp, currentUsers, ws, (result) => { + if (result === "User banned") { + ws.close(1008, 'Banned IP'); + return; + } - const { isAdminAuthenticated, isTuneAuthenticated } = request.session || {}; - - if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) { - output.write(`${command}\n`); - } - - }); - - ws.on('close', (code, reason) => { - if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.0.1') || (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { - currentUsers--; - } dataHandler.showOnlineUsers(currentUsers); - const index = storage.connectedUsers.findIndex(user => user.ip === clientIp); - if (index !== -1) { - storage.connectedUsers.splice(index, 1); + if (currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) { + serverConfig.xdrd.wirelessConnection ? connectToXdrd() : serialport.write('x\n'); } - - if(currentUsers === 0) { - storage.connectedUsers = []; - } - - if (currentUsers === 0 && serverConfig.enableDefaultFreq === true && serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) { - setTimeout(function() { - if(currentUsers === 0) { - output.write('T' + Math.round(serverConfig.defaultFreq * 1000) +'\n'); - dataHandler.resetToDefault(dataHandler.dataToSend); - dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3); - dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3); - } - }, 10000) - } - - if (currentUsers === 0 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection === true) { - client.write('X\n'); - } - - logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); }); - ws.on('error', console.error); -}); + const userCommands = {}; + let lastWarn = { time: 0 }; + ws.on('message', (message) => { + const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text'); + + if (((command.startsWith('X') || command.startsWith('Y')) && !request.session.isAdminAuthenticated) || + ((command.startsWith('F') || command.startsWith('W')) && serverConfig.bwSwitch === false)) { + logWarn(`User \x1b[90m${clientIp}\x1b[0m attempted to send a potentially dangerous command. You may consider blocking this user.`); + return; + } + + if (command.includes("\'")) { + return; + } + + const { isAdminAuthenticated, isTuneAuthenticated } = request.session || {}; + + if (command.startsWith('w') && (isAdminAuthenticated || isTuneAuthenticated)) { + switch (command) { + case 'wL1': + if (isAdminAuthenticated) serverConfig.lockToAdmin = true; + break; + case 'wL0': + if (isAdminAuthenticated) serverConfig.lockToAdmin = false; + break; + case 'wT0': + serverConfig.publicTuner = true; + if(!isAdminAuthenticated) tunerLockTracker.delete(ws); + break; + case 'wT1': + serverConfig.publicTuner = false; + if(!isAdminAuthenticated) tunerLockTracker.set(ws, true); + break; + default: + break; + } + } + + if (command.startsWith('T')) { + const tuneFreq = Number(command.slice(1)) / 1000; + const { tuningLimit, tuningLowerLimit, tuningUpperLimit } = serverConfig.webserver; + + if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) { + return; + } + } + + if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) { + output.write(`${command}\n`); + } + }); + + ws.on('close', (code, reason) => { + if (clientIp !== '::ffff:127.0.0.1' || + (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.1') || + (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { + currentUsers--; + } + dataHandler.showOnlineUsers(currentUsers); + + const index = storage.connectedUsers.findIndex(user => user.ip === clientIp); + if (index !== -1) { + storage.connectedUsers.splice(index, 1); + } + + if (currentUsers === 0) { + storage.connectedUsers = []; + } + + if (tunerLockTracker.has(ws)) { + logInfo(`User who locked the tuner left. Unlocking the tuner.`); + output.write('wT0\n') + tunerLockTracker.delete(ws); + serverConfig.publicTuner = true; + } + + if (currentUsers === 0 && serverConfig.enableDefaultFreq === true && + serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) { + setTimeout(function() { + if (currentUsers === 0) { + output.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n'); + dataHandler.resetToDefault(dataHandler.dataToSend); + dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3); + dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3); + } + }, 10000); + } + + if (currentUsers === 0 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection === true) { + client.write('X\n'); + } + + if (code !== 1008) { + logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); + } + }); + + ws.on('error', console.error); +}); // CHAT WEBSOCKET BLOCK chatWss.on('connection', (ws, request) => { diff --git a/web/css/breadcrumbs.css b/web/css/breadcrumbs.css index 5ed2e92..ec80b74 100644 --- a/web/css/breadcrumbs.css +++ b/web/css/breadcrumbs.css @@ -14,9 +14,16 @@ h1 { } h1#tuner-name { - font-size: 32px; + font-size: 26px; font-weight: 300; text-transform: initial; + user-select: none; + cursor: pointer; + transition: 0.3s ease color; +} + +h1#tuner-name:hover { + color: var(--color-main-bright); } h2 { @@ -43,7 +50,8 @@ h4 { .tooltiptext { position: absolute; - background-color: var(--color-3); + background-color: var(--color-2); + border: 2px solid var(--color-3); color: var(--color-text); text-align: center; font-size: 14px; @@ -54,7 +62,7 @@ h4 { transition: opacity 0.3s ease; } -p#tuner-desc { +p.tuner-desc { margin: 0; } @@ -116,61 +124,63 @@ table .form-group { margin: 0; } -#settings, #back-btn, #users-online-container { - background: transparent; - border: 0; - color: var(--color-text); +.hidden-panel { + display: none; position: absolute; - top: 15px; - right: 15px; - font-size: 16px; - width: 64px; - height: 64px; - line-height: 64px; - text-align: center; - border-radius: 50%; - transition: 500ms ease background; - cursor: pointer; + left: 0; + top: 100%; + width: 100%; + max-width: 1160px; + background: var(--color-1-transparent); + color: white; + text-align: left; + padding: 20px; + backdrop-filter: blur(5px); + z-index: 10; + border-radius: 0 0 15px 15px; } -.chatbutton.hide-desktop { +#settings, #users-online-container, .chatbutton { background: transparent; border: 0; - color: var(--color-text); - position: absolute; - top: 15px; - left: 15px; + color: var(--color-4); font-size: 16px; - width: 64px; - height: 64px; - line-height: 64px; + width: 48px; + height: 48px; text-align: center; - border-radius: 50%; - transition: 500ms ease background; + border-radius: 15px; + transition: 300ms ease background; cursor: pointer; + margin: 2px; } -#settings:hover, #back-btn:hover, #users-online-container:hover { - background: var(--color-3); +#users-online-container { + margin-left: 10px; +} + +.chatbutton, #settings { + background-color: var(--color-1); +} + +#settings:hover, #users-online-container:hover, .chatbutton:hover { + background: var(--color-2); } #users-online-container { top: 80px; } -#back-btn { - left: 15px; - right: auto; +#af-list { + overflow-y: auto; + max-height: 345px; } #af-list ul { - display:list-item; + display: list-item; padding: 0; list-style-type: none; - margin-bottom: 0; + margin: 0; font-size: 14px; - max-height: 380px; - overflow-y: auto; } #af-list a { @@ -192,9 +202,9 @@ table .form-group { margin-bottom: 0; display: none; cursor: pointer; - } - - .checkbox label { +} + +.checkbox label { cursor: pointer; display: block; user-select: none; @@ -204,45 +214,45 @@ table .form-group { border: 2px solid var(--color-4); box-sizing: border-box; transition: 0.35s ease background-color, 0.35s ease color; - } - .checkbox label:hover { +} +.checkbox label:hover { background-color: var(--color-2); - } - - .form-group input:checked + label { +} + +.form-group input:checked + label { background-color: var(--color-4); color: var(--color-main); - } +} - .tuner-info { +.tuner-info { margin-top: 0px !important; margin-bottom: 0px !important; - } +} - h2.settings-heading { +h2.settings-heading { font-size: 42px; padding: 10px 0; font-weight: 300; - } +} - h3.settings-heading { +h3.settings-heading { font-size: 24px; text-transform: uppercase; font-weight: 300; margin-bottom: 5px; - } +} - #tuner-wireless { +#tuner-wireless { display: none; - } +} - #flags-container-phone, - #flags-container-desktop { +#flags-container-phone, +#flags-container-desktop { position: relative; /* Confine overlay within container which is necessary for iPhones */ - } +} - #flags-container-phone .overlay, - #flags-container-desktop .overlay { +#flags-container-phone .overlay, +#flags-container-desktop .overlay { position: absolute; top: 0; left: 0; @@ -321,23 +331,28 @@ pre { position: relative; } +.text-200-px { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 200px; +} + @media (max-width: 768px) { canvas, #flags-container { display: none; } - #tuner-desc { + .tuner-desc { margin-bottom: 20px !important; + text-align: center; } #ps-container { background-color: var(--color-1-transparent); height: 100px !important; margin: auto !important; + margin-top: 30px !important; width: 100%; } - h1#tuner-name { - max-width: 90%; - margin: auto; - } h2 { display: none; } @@ -364,7 +379,7 @@ pre { font-size: 42px; } #data-frequency { - font-size: 64px; + font-size: 58px; } #data-rt0, #data-rt1 { font-size: 10px; @@ -426,7 +441,7 @@ pre { } } -@media only screen and (min-width: 769px) and (max-height: 860px) { +@media only screen and (min-width: 769px) and (max-height: 720px) { #rt-container { height: 90px !important; } @@ -441,38 +456,33 @@ pre { font-size: 24px; text-align: left; } - + .tuner-info #tuner-limit { float: left; text-align: left; } - .tuner-info #tuner-desc { - float: right; - text-align: right; - } h2 { margin-bottom: 10px; font-size: 18px; } - + h2.signal-heading { margin-bottom: 0; } - + .highest-signal-container { margin-bottom: -10px !important; } - + h2.mb-0 { margin-bottom: 0; margin-top: 2px !important; } - #af-list ul { - height: 225px !important; - } - .chatbutton { - height: 88px !important; + + #af-list { + overflow-y: auto; + max-height: 330px; } } diff --git a/web/css/helpers.css b/web/css/helpers.css index 676ea85..6f846f2 100644 --- a/web/css/helpers.css +++ b/web/css/helpers.css @@ -71,6 +71,11 @@ margin: 0 !important; } + +.m-10 { + margin: 10px; +} + .m-left-20 { margin-left: 20px; } @@ -186,6 +191,10 @@ margin-top: 25px; } +.bottom-10 { + margin-bottom: 10px; +} + .bottom-20 { margin-bottom: 20px; } @@ -238,12 +247,34 @@ table .input-text { animation: blinker 1.5s infinite; } +.scrollable-container { + display: flex; + gap: 8px; + overflow-x: auto; /* Enables horizontal scrolling */ + white-space: nowrap; + scrollbar-width: none; /* Hide scrollbar in Firefox */ + -ms-overflow-style: none; /* Hide scrollbar in Edge */ +} + +/* Hide scrollbar for Chrome, Safari */ +.scrollable-container::-webkit-scrollbar { + display: none; +} + +/* Chevron styling */ +.scroll-left, +.scroll-right { + display: none; /* Hidden by default */ + cursor: pointer; + width: 48px; +} + @keyframes blinker { 0% { - background-color: var(--color-4); + background-color: var(--color-3); } 100% { - background-color: var(--color-2); + background-color: var(--color-1); } } @@ -286,7 +317,7 @@ table .input-text { } /* Laptop compact view */ -@media only screen and (min-width: 960px) and (max-height: 860px) { +@media only screen and (min-width: 960px) and (max-height: 720px) { .text-big { font-size: 40px; } diff --git a/web/css/main.css b/web/css/main.css index 77501b1..aa4a554 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -60,14 +60,22 @@ body { min-height: 100%; } -#wrapper-outer { +.wrapper-outer { width: 100%; height: auto; background-color: var(--color-main); display: flex; align-items: center; justify-content: center; - min-height:100vh; + flex-direction: column; +} + +.wrapper-outer:not(.dashboard-panel) { + min-height: calc(100vh - 84px); +} + +.wrapper-outer.wrapper-full { + min-height: 100vh; } .wrapper-outer-static { @@ -79,6 +87,7 @@ body { width: 100%; max-width: calc(0% + 1180px); } + #wrapper.setup-wrapper { margin: auto; position: static; @@ -135,15 +144,6 @@ hr { border-radius: 0px 15px 15px 0px; } -@media (max-width: 1180px) { - #wrapper { - position: static; - transform: none; - margin: 50px auto; - width: 100%; - } -} - @media (max-width: 768px) { #wrapper.setup-wrapper { width: 100%; diff --git a/web/css/modal.css b/web/css/modal.css index 9cbe6a8..0f4b9ed 100644 --- a/web/css/modal.css +++ b/web/css/modal.css @@ -107,11 +107,6 @@ body.modal-open { overflow-y: auto; } -.modal-panel-content .version-info { - margin-top: 20px; - width: 100%; -} - .modal-panel-footer { width: 450px; height: 100px; @@ -171,7 +166,7 @@ body.modal-open { width: 100%; } .modal-panel-chat { - height: 550px; + height: 510px; } #chat-chatbox { height: 333px !important; diff --git a/web/css/panels.css b/web/css/panels.css index 762bcb4..177e5a4 100644 --- a/web/css/panels.css +++ b/web/css/panels.css @@ -93,21 +93,13 @@ .panel-90 { margin-top: 100px; } -} - -/* Laptop compact view */ -@media only screen and (min-width: 960px) and (max-height: 860px) { - *[class^="panel-"] { - margin-top: 20px; + .panel-100-real.bg-phone { + background-color: var(--color-1-transparent); + backdrop-filteR: blur(5px) !important; + padding-left: 10px !important; + padding-right: 10px !important; } - .panel-90 { - margin-top: 0; - } - .panel-10 { - padding-bottom: 20px; - padding-right: 20px; - } - .panel-10.hide-phone { - padding: 0; + #dashboard-panel-description { + backdrop-filter: blur(25px) !important; } } \ No newline at end of file diff --git a/web/css/toast.css b/web/css/toast.css index dd73970..d253481 100644 --- a/web/css/toast.css +++ b/web/css/toast.css @@ -2,7 +2,7 @@ #toast-container { position: fixed; top: 20px; - right: 96px; + right: 32px; z-index: 9999; } diff --git a/web/index.ejs b/web/index.ejs index 7d4fc6e..60a5e0f 100644 --- a/web/index.ejs +++ b/web/index.ejs @@ -6,10 +6,10 @@ - + - + @@ -29,471 +29,521 @@ -
-
-
-

[ <%= tunerName %> ] - <% if (!publicTuner) { %> - <% } if (tunerLock) { %> - <% } %> +
+
+

+ <%= tunerName %>

-

- <%- tunerDesc %> - <% if(tuningLimit && tuningLimit == true){ %> -
Limit: <%= tuningLowerLimit %> MHz - <%= tuningUpperLimit %> MHz
- <% } %> -

-
-
-
- -
- -
-
-
-
-
- -
-
- -
-
- -
-

-
-

-

- TP - TA -
- -
- -
-
-
-
- -
- MS -

-
-
- -
-
-

PI CODE

-
-   -
- -
- -
-

FREQUENCY

- -
- -
-

SIGNAL

-
- - - <% if (device == 'sdr') { %> dB SNR <% } else { %> <% } %> -
-
- - <% if (device == 'sdr') { %> dB SNR <% } else { %> dBf <% } %> -
-
-
- -
-
-
-
- -
- - <% if (antennas.enabled == true) { %> - - <% } %> - -
- <% if (device == 'tef') { %><% } %> - <% if (device == 'xdr') { %><% } %> -
-
- <% if (device == 'tef') { %><% } %> - <% if (device == 'xdr') { %><% } %> -
-
-
- -
- - - -
- -
-
- - - - <% if (device == 'tef' && bwSwitch == true) { %> - - <% } %> - - <% if (device == 'xdr' && bwSwitch == true) { %> - - <% } %> - - <% if (device == 'sdr' && bwSwitch == true) { %> - - <% } %> - <% if (fmlist_integration == true) { %> - - <% } %> -
-
-
- -
-
-

RADIOTEXT

-
- -
-
- -
-
-
- -
-
-

- -

-

- [] -

- - kW [] - -
-
-
- -
- -
-
-

AF

-
- <% if (chatEnabled) { %>
    - <% } else { %> -
      - <% } %> - -
    -
-
- <% if (chatEnabled) { %> -
- -
+ <% if(!publicTuner || tunerLock) { %> +
+ <% if (!publicTuner) { %> + <% } if (tunerLock) { %> <% } %>
-
-
-

-
-

-

- TP - TA -
- -
- -
-
-
-
- -
- MS -

-
-
- - - <% if (chatEnabled) { %> - - <% } %> - - - -
-
- - -<% if (!noPlugins) { %> - <% plugins?.forEach(function(plugin) { %> - - <% }); %> -<% } %> - - +
+
+
+ +
+ +
+
+
+
+
+ +
+
+ +
+
+ +
+

+
+

+

+ TP + TA +
+ +
+ +
+
+
+
+ +
+ MS +

+
+
+ +
+
+

PI CODE

+
+   +
+ +
+ +
+

FREQUENCY

+ +
+ +
+

SIGNAL

+
+ + + <% if (device == 'sdr') { %> dB SNR <% } else { %> <% } %> +
+
+ + <% if (device == 'sdr') { %> dB SNR <% } else { %> dBf <% } %> +
+
+
+ +
+
+
+
+ +
+ + <% if (antennas.enabled == true) { %> + + <% } %> + +
+ <% if (device == 'tef') { %><% } %> + <% if (device == 'xdr') { %><% } %> +
+
+ <% if (device == 'tef') { %><% } %> + <% if (device == 'xdr') { %><% } %> +
+
+
+ +
+ + + +
+ +
+
+ + + + <% if (device == 'tef' && bwSwitch == true) { %> + + <% } %> + + <% if (device == 'xdr' && bwSwitch == true) { %> + + <% } %> + + <% if (device == 'sdr' && bwSwitch == true) { %> + + <% } %> + <% if (fmlist_integration == true) { %> + + <% } %> +
+
+
+ +
+
+

RADIOTEXT

+
+ +
+
+ +
+
+
+ +
+
+

+ +

+

+ [] +

+ + kW [] + +
+
+
+ +
+ +
+
+

AF

+
+
+
+
+ +
+
+
+

+
+

+

+ TP + TA +
+ +
+ +
+
+
+
+ +
+ MS +

+
+
+ + +
+
+ + + <% if (!noPlugins) { %> + <% plugins?.forEach(function(plugin) { %> + + <% }); %> + <% } %> + + + \ No newline at end of file diff --git a/web/js/chat.js b/web/js/chat.js index 9c9bdbc..3f48754 100644 --- a/web/js/chat.js +++ b/web/js/chat.js @@ -49,7 +49,7 @@ $(document).ready(function() { chatMessageCount++; chatMessagesCount.text(chatMessageCount); chatMessagesCount.attr("aria-label", "Chat (" + chatMessageCount + " unread)"); - chatButton.removeClass('bg-color-2').addClass('blink'); + chatButton.removeClass('bg-color-1').addClass('blink'); } } } @@ -67,7 +67,7 @@ $(document).ready(function() { chatButton.click(function() { chatMessageCount = 0; chatMessagesCount.text(chatMessageCount); - chatButton.removeClass('blink').addClass('bg-color-2'); + chatButton.removeClass('blink').addClass('bg-color-1'); chatSendInput.focus(); setTimeout(function() { diff --git a/web/js/init.js b/web/js/init.js index 9107cfe..643e188 100644 --- a/web/js/init.js +++ b/web/js/init.js @@ -1,9 +1,9 @@ -var currentDate = new Date('Feb 9, 2025 18:00:00'); +var currentDate = new Date('Feb 16, 2025 15:00:00'); var day = currentDate.getDate(); var month = currentDate.getMonth() + 1; // Months are zero-indexed, so add 1 var year = currentDate.getFullYear(); var formattedDate = day + '/' + month + '/' + year; -var currentVersion = 'v1.3.4 [' + formattedDate + ']'; +var currentVersion = 'v1.3.5 [' + formattedDate + ']'; getInitialSettings(); removeUrlParameters(); diff --git a/web/js/main.js b/web/js/main.js index 4ad4952..cdfc522 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -33,31 +33,31 @@ const rdsMode = localStorage.getItem('rdsMode'); $(document).ready(function () { var canvas = $('#signal-canvas')[0]; - + var $panel = $('.admin-quick-dashboard'); var panelWidth = $panel.outerWidth(); - + $(document).mousemove(function(e) { var mouseX = e.pageX; var panelLeft = parseInt($panel.css('left')); - + if (mouseX <= 10 || (panelLeft === 4 && mouseX <= 100)) { $panel.css('left', '4px'); } else { $panel.css('left', -panelWidth); } }); - + canvas.width = canvas.parentElement.clientWidth; canvas.height = canvas.parentElement.clientHeight; - + // Start updating the canvas initCanvas(); fillPresets(); - + signalToggle.on("change", function () { const signalText = localStorage.getItem('signalUnit'); - + if (signalText == 'dbuv') { signalText.text('dBµV'); } else if (signalText == 'dbf') { @@ -66,7 +66,7 @@ $(document).ready(function () { signalText.text('dBm'); } }); - + // Check if device is an iPhone to prevent zoom on button press if (/iPhone|iPod|iPad/.test(navigator.userAgent) && !window.MSStream) { const buttons = document.querySelectorAll('button'); @@ -81,9 +81,9 @@ $(document).ready(function () { }); }); } - + const textInput = $('#commandinput'); - + textInput.on('change blur', function (event) { const inputValue = Number(textInput.val()); // Check if the user agent contains 'iPhone' @@ -93,18 +93,18 @@ $(document).ready(function () { textInput.val(''); } }); - + textInput.on('keyup', function (event) { - + if (event.key !== 'Backspace' && localStorage.getItem('extendedFreqRange') != "true") { let inputValue = textInput.val(); inputValue = inputValue.replace(/[^0-9.]/g, ''); - + if (inputValue.includes("..")) { inputValue = inputValue.slice(0, inputValue.lastIndexOf('.')) + inputValue.slice(inputValue.lastIndexOf('.') + 1); textInput.val(inputValue); } - + if (!inputValue.includes(".")) { if (inputValue.startsWith('10') && inputValue.length > 2) { inputValue = inputValue.slice(0, 3) + '.' + inputValue.slice(3); @@ -123,31 +123,31 @@ $(document).ready(function () { textInput.val(''); } }); - + document.onkeydown = function(event) { if (!event.repeat) { checkKey(event); } }; - + let lastExecutionTime = 0; const throttleDelay = 100; // Time in ms $('#freq-container').on('wheel keypress', function (e) { e.preventDefault(); const now = Date.now(); - + if (now - lastExecutionTime < throttleDelay) { // Ignore this event as it's within the throttle delay return; } - + lastExecutionTime = now; // Update the last execution time - + getCurrentFreq(); var delta = e.originalEvent.deltaY; var adjustment = 0; - + if (e.shiftKey) { adjustment = e.altKey ? 1 : 0.01; } else if (e.ctrlKey) { @@ -160,17 +160,21 @@ $(document).ready(function () { } return false; } - + var newFreq = currentFreq + (delta > 0 ? -adjustment : adjustment); socket.send("T" + (Math.round(newFreq * 1000))); return false; }); - + setInterval(getServerTime, 10000); getServerTime(); setInterval(sendPingRequest, 5000); sendPingRequest(); - + + $("#tuner-name").click(function() { + showTunerDescription(); + }); + var freqUpButton = $('#freq-up')[0]; var freqDownButton = $('#freq-down')[0]; var psContainer = $('#ps-container')[0]; @@ -178,19 +182,19 @@ $(document).ready(function () { var piCodeContainer = $('#pi-code-container')[0]; var freqContainer = $('#freq-container')[0]; var txContainer = $('#data-station-container')[0]; - + $("#data-eq").click(function () { toggleButtonState("eq"); }); - + $("#data-ims").click(function () { toggleButtonState("ims"); }); - + $("#volumeSlider").on('mouseup', function() { $('#volumeSlider').blur(); }) - + $(freqUpButton).on("click", tuneUp); $(freqDownButton).on("click", tuneDown); $(psContainer).on("click", copyPs); @@ -202,37 +206,37 @@ $(document).ready(function () { textInput.focus(); }); initTooltips(); - + //FMLIST logging $('.popup-content').on('click', function(event) { event.stopPropagation(); $('.popup-content').removeClass('show'); }); - + $('#log-fmlist').on('click', function() { const logKey = 'fmlistLogChoice'; const logTimestampKey = 'fmlistLogTimestamp'; const expirationTime = 10 * 60 * 1000; const now = Date.now(); - + const storedChoice = localStorage.getItem(logKey); const storedTimestamp = localStorage.getItem(logTimestampKey); - + if (storedChoice && storedTimestamp && (now - storedTimestamp < expirationTime)) { sendLog(storedChoice); return; } - + if (parsedData.txInfo.dist > 700) { $('#log-fmlist .popup-content').addClass('show'); // Show popup if no valid choice - + $('#log-fmlist-sporadice').off('click').on('click', function () { localStorage.setItem(logKey, './log_fmlist?type=sporadice'); localStorage.setItem(logTimestampKey, now); if(parsedData.txInfo.dist > 700) sendLog('./log_fmlist?type=sporadice'); $('#log-fmlist .popup-content').removeClass('show'); }); - + $('#log-fmlist-tropo').off('click').on('click', function () { localStorage.setItem(logKey, './log_fmlist?type=tropo'); localStorage.setItem(logTimestampKey, now); @@ -242,7 +246,7 @@ $(document).ready(function () { } else { sendLog('./log_fmlist'); } - + function sendLog(endpoint) { $.ajax({ url: endpoint, @@ -252,18 +256,18 @@ $(document).ready(function () { }, error: function(xhr) { let errorMessage; - + switch (xhr.status) { case 429: - errorMessage = xhr.responseText; - break; + errorMessage = xhr.responseText; + break; case 500: - errorMessage = 'Server error: ' + (xhr.responseText || 'Internal Server Error'); - break; + errorMessage = 'Server error: ' + (xhr.responseText || 'Internal Server Error'); + break; default: - errorMessage = xhr.statusText || 'An error occurred'; + errorMessage = xhr.statusText || 'An error occurred'; } - + sendToast('error', 'Log failed', errorMessage, false, true); } }); @@ -274,43 +278,43 @@ $(document).ready(function () { function getServerTime() { $.ajax({ - url: "./server_time", - dataType: "json", - success: function(data) { - const serverTimeUtc = data.serverTime; - - const options = { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false - }; - - const serverOptions = { - ...options, - timeZone: 'Etc/UTC' // Add timeZone only for server time - }; - - const formattedServerTime = new Date(serverTimeUtc).toLocaleString(navigator.language ? navigator.language : 'en-US', serverOptions); - - $("#server-time").text(formattedServerTime); - - // Get and format user's local time directly without specifying timeZone: - const localTime = new Date(); - const formattedLocalTime = new Date(localTime).toLocaleString(navigator.language ? navigator.language : 'en-US', options); - - // Display client time: - $("#client-time").text(formattedLocalTime); - }, - error: function(jqXHR, textStatus, errorThrown) { - console.error("Error fetching server time:", errorThrown); - // Handle error gracefully (e.g., display a fallback message) - } + url: "./server_time", + dataType: "json", + success: function(data) { + const serverTimeUtc = data.serverTime; + + const options = { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }; + + const serverOptions = { + ...options, + timeZone: 'Etc/UTC' // Add timeZone only for server time + }; + + const formattedServerTime = new Date(serverTimeUtc).toLocaleString(navigator.language ? navigator.language : 'en-US', serverOptions); + + $("#server-time").text(formattedServerTime); + + // Get and format user's local time directly without specifying timeZone: + const localTime = new Date(); + const formattedLocalTime = new Date(localTime).toLocaleString(navigator.language ? navigator.language : 'en-US', options); + + // Display client time: + $("#client-time").text(formattedLocalTime); + }, + error: function(jqXHR, textStatus, errorThrown) { + console.error("Error fetching server time:", errorThrown); + // Handle error gracefully (e.g., display a fallback message) + } }); - } - +} + function sendPingRequest() { const timeoutDuration = 15000; // Ping response can become buggy if it exceeds 20 seconds const startTime = new Date().getTime(); @@ -320,37 +324,37 @@ function sendPingRequest() { const timerTimeout = setTimeout(() => { reject(new Error('Request timed out')); }, timeout); - + fetch(url, options) - .then(response => { - clearTimeout(timerTimeout); - resolve(response); - }) - .catch(error => { - clearTimeout(timerTimeout); - reject(error); - }); + .then(response => { + clearTimeout(timerTimeout); + resolve(response); + }) + .catch(error => { + clearTimeout(timerTimeout); + reject(error); + }); }); }; - + fetchWithTimeout('./ping', { cache: 'no-store' }, timeoutDuration) - .then(response => { - const endTime = new Date().getTime(); - const pingTime = endTime - startTime; - $('#current-ping').text(`Ping: ${pingTime}ms`); - pingTimeLimit = false; - }) - .catch(error => { - console.warn('Ping request failed'); - $('#current-ping').text(`Ping: unknown`); - if (!pingTimeLimit) { // Force reconnection as WebSocket could be unresponsive even though it's reported as OPEN - if (messageLength === 0) window.socket.close(1000, 'Normal closure'); - if (connectionLost) sendToast('warning', 'Connection lost', 'Attempting to reconnect...', false, false); - console.log("Reconnecting due to high ping..."); - pingTimeLimit = true; - } - }); - + .then(response => { + const endTime = new Date().getTime(); + const pingTime = endTime - startTime; + $('#current-ping').text(`Ping: ${pingTime}ms`); + pingTimeLimit = false; + }) + .catch(error => { + console.warn('Ping request failed'); + $('#current-ping').text(`Ping: unknown`); + if (!pingTimeLimit) { // Force reconnection as WebSocket could be unresponsive even though it's reported as OPEN + if (messageLength === 0) window.socket.close(1000, 'Normal closure'); + if (connectionLost) sendToast('warning', 'Connection lost', 'Attempting to reconnect...', false, false); + console.log("Reconnecting due to high ping..."); + pingTimeLimit = true; + } + }); + function handleMessage(message) { messageData = JSON.parse(message.data.length); socket.removeEventListener('message', handleMessage); @@ -361,21 +365,21 @@ function sendPingRequest() { // Force reconnection if no WebSocket data after several queries if (messageLength === 0) { - messageCounter++; - if (messageCounter === 5) { - messageCounter = 0; - window.socket.close(1000, 'Normal closure'); - if (connectionLost) sendToast('warning', 'Connection lost', 'Attempting to reconnect...', false, false); - console.log("Reconnecting due to no data received..."); - } + messageCounter++; + if (messageCounter === 5) { + messageCounter = 0; + window.socket.close(1000, 'Normal closure'); + if (connectionLost) sendToast('warning', 'Connection lost', 'Attempting to reconnect...', false, false); + console.log("Reconnecting due to no data received..."); + } } else { - messageCounter = 0; + messageCounter = 0; } // Automatic reconnection on WebSocket close if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) { socket = new WebSocket(socketAddress); - + socket.onopen = () => { sendToast('info', 'Connected', 'Reconnected successfully!', false, false); }; @@ -390,16 +394,16 @@ function sendPingRequest() { }; } if (connectionLost) { - if (dataTimeout == dataTimeoutPrevious) { - connectionLost = true; - } else { - setTimeout(() => { - window.socket.close(1000, 'Normal closure'); // Force reconnection to unfreeze browser UI - }, 8000); // Timeout must be higher than TIMEOUT_DURATION - connectionLost = false; - requiresAudioStreamRestart = true; - console.log("Radio data restored."); - } + if (dataTimeout == dataTimeoutPrevious) { + connectionLost = true; + } else { + setTimeout(() => { + window.socket.close(1000, 'Normal closure'); // Force reconnection to unfreeze browser UI + }, 8000); // Timeout must be higher than TIMEOUT_DURATION + connectionLost = false; + requiresAudioStreamRestart = true; + console.log("Radio data restored."); + } } } @@ -408,16 +412,16 @@ function handleWebSocketMessage(event) { if (event.data == 'KICK') { console.log('Kick initiated.') setTimeout(() => { - window.location.href = '/403'; + window.location.href = '/403'; }, 500); return; } - + parsedData = JSON.parse(event.data); - + resetDataTimeout(); updatePanels(parsedData); - + const sum = signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0); const averageSignal = sum / signalData.length; data.push(averageSignal); @@ -427,7 +431,7 @@ socket.onmessage = handleWebSocketMessage; function initCanvas(parsedData) { signalToggle = $("#signal-units-toggle"); - + // Check if signalChart is already initialized if (!signalChart) { const canvas = $('#signal-canvas')[0]; @@ -435,7 +439,7 @@ function initCanvas(parsedData) { const maxDataPoints = 300; const pointWidth = (canvas.width - 80) / maxDataPoints; - + signalChart = { canvas, context, @@ -448,7 +452,7 @@ function initCanvas(parsedData) { signalUnit: localStorage.getItem('signalUnit'), offset: 0, }; - + switch(signalChart.signalUnit) { case 'dbuv': signalChart.offset = 11.25; break; case 'dbm': signalChart.offset = 120; break; @@ -462,7 +466,7 @@ function initCanvas(parsedData) { updateChartSettings(signalChart); }, 1000); // Check every 1 second } - + updateCanvas(parsedData, signalChart); } @@ -476,7 +480,7 @@ function updateChartSettings(signalChart) { signalChart.color3 = newColor3; signalChart.color4 = newColor4; } - + // Update signal unit const newSignalUnit = localStorage.getItem('signalUnit'); if (newSignalUnit !== signalChart.signalUnit) { @@ -492,27 +496,27 @@ function updateChartSettings(signalChart) { function updateCanvas(parsedData, signalChart) { const { context, canvas, maxDataPoints, pointWidth, color2, color3, color4, offset } = signalChart; - + if (data.length > maxDataPoints) { data = data.slice(data.length - maxDataPoints); } - + const actualLowestValue = Math.min(...data); const actualHighestValue = Math.max(...data); const zoomMinValue = actualLowestValue - ((actualHighestValue - actualLowestValue) / 2); const zoomMaxValue = actualHighestValue + ((actualHighestValue - actualLowestValue) / 2); const zoomAvgValue = (zoomMaxValue - zoomMinValue) / 2 + zoomMinValue; - + // Clear the canvas context.clearRect(0, 0, canvas.width, canvas.height); context.beginPath(); - + const startingIndex = Math.max(0, data.length - maxDataPoints); - + for (let i = startingIndex; i < data.length; i++) { const x = canvas.width - (data.length - i) * pointWidth - 40; const y = canvas.height - (data[i] - zoomMinValue) * (canvas.height / (zoomMaxValue - zoomMinValue)); - + if (i === startingIndex) { context.moveTo(x, y); } else { @@ -520,53 +524,53 @@ function updateCanvas(parsedData, signalChart) { const prevY = canvas.height - (data[i - 1] - zoomMinValue) * (canvas.height / (zoomMaxValue - zoomMinValue)); const interpolatedX = (x + prevX) / 2; const interpolatedY = (y + prevY) / 2; - + context.quadraticCurveTo(prevX, prevY, interpolatedX, interpolatedY); } } - + context.strokeStyle = color4; context.lineWidth = 2; context.stroke(); - + // Draw horizontal lines for lowest, highest, and average values context.strokeStyle = color3; context.lineWidth = 1; - + // Draw the lowest value line const lowestY = canvas.height - (zoomMinValue - zoomMinValue) * (canvas.height / (zoomMaxValue - zoomMinValue)); context.beginPath(); context.moveTo(40, lowestY - 18); context.lineTo(canvas.width - 40, lowestY - 18); context.stroke(); - + // Draw the highest value line const highestY = canvas.height - (zoomMaxValue - zoomMinValue) * (canvas.height / (zoomMaxValue - zoomMinValue)); context.beginPath(); context.moveTo(40, highestY + 10); context.lineTo(canvas.width - 40, highestY + 10); context.stroke(); - + const avgY = canvas.height / 2; context.beginPath(); context.moveTo(40, avgY - 7); context.lineTo(canvas.width - 40, avgY - 7); context.stroke(); - + // Label the lines with their values context.fillStyle = color4; context.font = '12px Titillium Web'; - + context.textAlign = 'right'; context.fillText(`${(zoomMinValue - offset).toFixed(1)}`, 35, lowestY - 14); context.fillText(`${(zoomMaxValue - offset).toFixed(1)}`, 35, highestY + 14); context.fillText(`${(zoomAvgValue - offset).toFixed(1)}`, 35, avgY - 3); - + context.textAlign = 'left'; context.fillText(`${(zoomMinValue - offset).toFixed(1)}`, canvas.width - 35, lowestY - 14); context.fillText(`${(zoomMaxValue - offset).toFixed(1)}`, canvas.width - 35, highestY + 14); context.fillText(`${(zoomAvgValue - offset).toFixed(1)}`, canvas.width - 35, avgY - 3); - + setTimeout(() => { requestAnimationFrame(() => updateCanvas(parsedData, signalChart)); }, 1000 / 15); @@ -597,12 +601,12 @@ socket.onmessage = (event) => { }, 500); return; } - + parsedData = JSON.parse(event.data); - + resetDataTimeout(); updatePanels(parsedData); - + const sum = signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0); const averageSignal = sum / signalData.length; data.push(averageSignal); @@ -624,7 +628,7 @@ function processString(string, errors) { const alpha_range = 50; const max_error = 10; errors = errors?.split(','); - + for (let i = 0; i < string.length; i++) { alpha = parseInt(errors[i]) * (alpha_range / (max_error + 1)); if (alpha) { @@ -633,7 +637,7 @@ function processString(string, errors) { output += escapeHTML(string[i]); } } - + return output; } @@ -641,17 +645,17 @@ function getCurrentFreq() { currentFreq = $('#data-frequency').text(); currentFreq = parseFloat(currentFreq).toFixed(3); currentFreq = parseFloat(currentFreq); - + return currentFreq; } function checkKey(e) { e = e || window.event; - + if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) { return; } - + if ($('#password:focus').length > 0 || $('#chat-send-message:focus').length > 0 || $('#volumeSlider:focus').length > 0 @@ -659,9 +663,9 @@ function checkKey(e) { || $('.option:focus').length > 0) { return; } - + getCurrentFreq(); - + if (socket.readyState === WebSocket.OPEN) { switch (e.keyCode) { case 66: // Back to previous frequency @@ -673,7 +677,7 @@ function checkKey(e) { case 83: // Screenshot (S key) break; case 38: - socket.send("T" + (Math.round(currentFreq*1000) + ((currentFreq > 30) ? 10 : 1))); + socket.send("T" + (Math.round(currentFreq*1000) + ((currentFreq > 30) ? 10 : 1))); break; case 40: socket.send("T" + (Math.round(currentFreq*1000) - ((currentFreq > 30) ? 10 : 1))); @@ -684,25 +688,50 @@ function checkKey(e) { case 39: tuneUp(); break; + case 46: + let $dropdown = $("#data-ant"); + let $input = $dropdown.find("input"); + let $options = $dropdown.find("ul.options .option"); + + if ($options.length === 0) return; // No antennas available + + // Find the currently selected antenna + let currentText = $input.attr("placeholder").trim(); + let currentIndex = $options.index($options.filter(function () { + return $(this).text().trim() === currentText; + })); + + // Cycle to the next option + let nextIndex = (currentIndex + 1) % $options.length; + let $nextOption = $options.eq(nextIndex); + + // Update UI + $input.attr("placeholder", $nextOption.text()); + $input.data("value", $nextOption.data("value")); + + // Send socket message (e.g., "Z0", "Z1", ...) + let socketMessage = "Z" + $nextOption.data("value"); + socket.send(socketMessage); + break; case 112: // F1 e.preventDefault(); tuneTo(Number(localStorage.getItem('preset1'))); - break; + break; case 113: // F2 e.preventDefault(); tuneTo(Number(localStorage.getItem('preset2'))); - break; + break; case 114: // F3 e.preventDefault(); tuneTo(Number(localStorage.getItem('preset3'))); - break; + break; case 115: // F4 e.preventDefault(); tuneTo(Number(localStorage.getItem('preset4'))); - break; + break; default: - // Handle default case if needed - break; + // Handle default case if needed + break; } previousFreq = currentFreq; } @@ -715,7 +744,7 @@ async function copyPs() { var signal = $('#data-signal').text(); var signalDecimal = $('#data-signal-decimal').text(); var signalUnit = $('.signal-units').eq(0).text(); - + try { await copyToClipboard(frequency + " - " + pi + " | " + ps + " [" + signal + signalDecimal + " " + signalUnit + "]"); } catch (error) { @@ -731,7 +760,7 @@ async function copyTx() { const stationItu = $('#data-station-itu').text(); const stationDistance = $('#data-station-distance').text(); const stationErp = $('#data-station-erp').text(); - + try { await copyToClipboard(frequency + " - " + pi + " | " + stationName + " [" + stationCity + ", " + stationItu + "] - " + stationDistance + " | " + stationErp + " kW"); } catch (error) { @@ -742,7 +771,7 @@ async function copyTx() { async function copyRt() { var rt0 = $('#data-rt0 span').text(); var rt1 = $('#data-rt1 span').text(); - + try { await copyToClipboard("[0] RT: " + rt0 + "\n[1] RT: " + rt1); } catch (error) { @@ -754,9 +783,9 @@ function copyToClipboard(textToCopy) { // Navigator clipboard api needs a secure context (https) if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(textToCopy) - .catch(function (err) { - console.error('Error:', err); - }); + .catch(function (err) { + console.error('Error:', err); + }); } else { var textArea = $(''); textArea.val(textToCopy); @@ -764,10 +793,10 @@ function copyToClipboard(textToCopy) { 'position': 'absolute', 'left': '-999999px' }); - + $('body').prepend(textArea); textArea.select(); - + try { document.execCommand('copy'); } catch (error) { @@ -783,9 +812,9 @@ function findOnMaps() { var pi = $('#data-pi').text(); var latitude = localStorage.getItem('qthLongitude'); var longitude = localStorage.getItem('qthLatitude'); - + frequency > 74 ? frequency = frequency.toFixed(1) : null; - + var url = `https://maps.fmdx.org/#qth=${longitude},${latitude}&freq=${frequency}&findPi=${pi}`; window.open(url, "_blank"); } @@ -795,33 +824,33 @@ function updateSignalUnits(parsedData, averageSignal) { const signalUnit = localStorage.getItem('signalUnit'); let currentSignal; let highestSignal = parsedData.sigTop; - + currentSignal = averageSignal let signalText = $('.signal-units'); let signalValue; - + switch (signalUnit) { case 'dbuv': - signalValue = currentSignal - 11.25; - highestSignal = highestSignal - 11.25; - signalText.text('dBµV'); - break; - + signalValue = currentSignal - 11.25; + highestSignal = highestSignal - 11.25; + signalText.text('dBµV'); + break; + case 'dbm': - signalValue = currentSignal - 120; - highestSignal = highestSignal - 120; - signalText.text('dBm'); - break; - + signalValue = currentSignal - 120; + highestSignal = highestSignal - 120; + signalText.text('dBm'); + break; + default: - signalValue = currentSignal; - signalText.text('dBf'); - break; + signalValue = currentSignal; + signalText.text('dBf'); + break; } - + const formatted = (Math.round(signalValue * 10) / 10).toFixed(1); const [integerPart, decimalPart] = formatted.split('.'); - + $('#data-signal-highest').text(Number(highestSignal).toFixed(1)); $('#data-signal').text(integerPart); $('#data-signal-decimal').text('.' + decimalPart); @@ -846,28 +875,28 @@ const $dataPty = $('.data-pty'); // Throttling function to limit the frequency of updates function throttle(fn, wait) { - let isThrottled = false, savedArgs, savedThis; - - function wrapper() { - if (isThrottled) { - savedArgs = arguments; - savedThis = this; - return; + let isThrottled = false, savedArgs, savedThis; + + function wrapper() { + if (isThrottled) { + savedArgs = arguments; + savedThis = this; + return; + } + + fn.apply(this, arguments); + isThrottled = true; + + setTimeout(function() { + isThrottled = false; + if (savedArgs) { + wrapper.apply(savedThis, savedArgs); + savedArgs = savedThis = null; + } + }, wait); } - - fn.apply(this, arguments); - isThrottled = true; - - setTimeout(function() { - isThrottled = false; - if (savedArgs) { - wrapper.apply(savedThis, savedArgs); - savedArgs = savedThis = null; - } - }, wait); - } - - return wrapper; + + return wrapper; } // Utility function to update element's text if changed @@ -889,24 +918,23 @@ const updateDataElements = throttle(function(parsedData) { updateTextIfChanged($dataFrequency, parsedData.freq); $commandInput.attr("aria-label", "Current frequency: " + parsedData.freq); updateHtmlIfChanged($dataPi, parsedData.pi === '?' ? "?" : parsedData.pi); - + if ($('#ps-underscores').is(':checked')) { parsedData.ps = parsedData.ps.replace(/\s/g, '_'); } updateHtmlIfChanged($dataPs, parsedData.ps === '?' ? "?" : processString(parsedData.ps, parsedData.ps_errors)); - - let stereoColor; + if(parsedData.st) { - stereoColor = 'var(--color-4)'; + $dataSt.parent().removeClass('opacity-half'); } else { - stereoColor = 'var(--color-3)'; + $dataSt.parent().addClass('opacity-half'); } - + if(parsedData.stForced) { if (!parsedData.st) { - stereoColor = 'gray'; + stereoColor = 'gray'; } else { - stereoColor = 'var(--color-4)'; + stereoColor = 'var(--color-4)'; } $('.data-st.circle1').css('left', '4px'); $('.data-st.circle2').css('display', 'none'); @@ -914,30 +942,29 @@ const updateDataElements = throttle(function(parsedData) { $('.data-st.circle1').css('left', '0px'); $('.data-st.circle2').css('display', 'block'); } - $dataSt.css('border', '2px solid ' + stereoColor); - + updateHtmlIfChanged($dataRt0, processString(parsedData.rt0, parsedData.rt0_errors)); updateHtmlIfChanged($dataRt1, processString(parsedData.rt1, parsedData.rt1_errors)); - + updateTextIfChanged($dataPty, rdsMode == 'true' ? usa_programmes[parsedData.pty] : europe_programmes[parsedData.pty]); - + if (parsedData.rds === true) { $flagDesktopCointainer.css('background-color', 'var(--color-2-transparent)'); } else { $flagDesktopCointainer.css('background-color', 'var(--color-1-transparent)'); } - + $('.data-flag').html(``); $('.data-flag-big').html(``); - + $dataAntInput.val($('#data-ant li[data-value="' + parsedData.ant + '"]').text()); - + if(parsedData.bw < 500) { $dataBwInput.val($('#data-bw li[data-value2="' + parsedData.bw + '"]').text()); } else { $dataBwInput.val($('#data-bw li[data-value="' + parsedData.bw + '"]').text()); } - + if (parsedData.txInfo.tx.length > 1) { updateTextIfChanged($('#data-station-name'), parsedData.txInfo.tx.replace(/%/g, '%25')); updateTextIfChanged($('#data-station-erp'), parsedData.txInfo.erp); @@ -951,14 +978,14 @@ const updateDataElements = throttle(function(parsedData) { } else { $dataStationContainer.removeAttr('style'); } - + if(parsedData.txInfo.tx.length > 1 && parsedData.txInfo.dist > 150 && parsedData.txInfo.dist < 4000) { $('#log-fmlist').removeAttr('disabled').removeClass('btn-disabled cursor-disabled'); } else { $('#log-fmlist').attr('disabled', 'true').addClass('btn-disabled cursor-disabled'); } updateHtmlIfChanged($('#data-regular-pi'), parsedData.txInfo.reg === true ? parsedData.txInfo.pi : ' '); - + updateCounter++; if (updateCounter % 8 === 0) { $dataTp.html(parsedData.tp === 0 ? "TP" : "TP"); @@ -971,7 +998,7 @@ const updateDataElements = throttle(function(parsedData) { ) ); } - + if (updateCounter % 30 === 0) { $dataPs.attr('aria-label', parsedData.ps); $dataRt0.attr('aria-label', parsedData.rt0); @@ -984,36 +1011,36 @@ let isEventListenerAdded = false; function updatePanels(parsedData) { updateCounter++; - + signalData.push(parsedData.sig); if (signalData.length > 8) { signalData.shift(); // Remove the oldest element } const sum = signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0); const averageSignal = sum / signalData.length; - + const sortedAf = parsedData.af.sort(compareNumbers); const scaledArray = sortedAf.map(element => element / 1000); - + const listContainer = $('#af-list'); const scrollTop = listContainer.scrollTop(); let ul = listContainer.find('ul'); - + if (!ul.length) { ul = $('
    '); listContainer.append(ul); } - + if (updateCounter % 3 === 0) { - + updateButtonState("data-eq", parsedData.eq); updateButtonState("data-ims", parsedData.ims); - + // Only update #af-list on every 3rd call ul.html(''); const listItems = scaledArray.map(createListItem); ul.append(listItems); - + // Add the event listener only once if (!isEventListenerAdded) { ul.on('click', 'a', function () { @@ -1022,10 +1049,10 @@ function updatePanels(parsedData) { }); isEventListenerAdded = true; } - + listContainer.scrollTop(scrollTop); } - + updateDataElements(parsedData); updateSignalUnits(parsedData, averageSignal); $('.users-online').text(parsedData.users); @@ -1063,7 +1090,7 @@ function toggleForcedStereo() { function toggleLock(buttonSelector, activeMessage, inactiveMessage, activeLabel, inactiveLabel) { let $lockButton = $(buttonSelector); - + if ($lockButton.hasClass('active')) { socket.send(inactiveMessage); $lockButton.attr('aria-label', inactiveLabel); @@ -1075,66 +1102,111 @@ function toggleLock(buttonSelector, activeMessage, inactiveMessage, activeLabel, } } - -function initTooltips() { - $('.tooltip').hover(function (e) { - // Check if hovered element is NOT `.popup-content` - if ($(e.target).closest('.popup-content').length) { - return; +function showTunerDescription() { + let parentDiv = $("#dashboard-panel-description").parent(); + + if (!$("#dashboard-panel-description").is(":visible")) { + parentDiv.css("border-radius", "15px 15px 0 0"); + } + + $("#dashboard-panel-description").slideToggle(300, function() { + if (!$(this).is(":visible")) { + parentDiv.css("border-radius", ""); } - - var tooltipText = $(this).data('tooltip'); - - // Delay tooltip appearance - $(this).data('timeout', setTimeout(() => { - if ($('.tooltip-wrapper').length === 0) { - var tooltip = $(` -
    -
    - ${tooltipText} -
    -
    - `); - $('body').append(tooltip); - } - - var posX = e.pageX; - var posY = e.pageY; - var tooltipEl = $('.tooltiptext'); - var tooltipWidth = tooltipEl.outerWidth(); - var tooltipHeight = tooltipEl.outerHeight(); - posX -= tooltipWidth / 2; - posY -= tooltipHeight + 10; - - tooltipEl.css({ top: posY, left: posX, opacity: 1 }); - - }, 500)); - }, function () { - clearTimeout($(this).data('timeout')); - setTimeout(() => { $('.tooltip-wrapper').remove(); }, 500); - }).mousemove(function (e) { - var tooltipEl = $('.tooltiptext'); - var tooltipWidth = tooltipEl.outerWidth(); - var tooltipHeight = tooltipEl.outerHeight(); - var posX = e.pageX - tooltipWidth / 2; - var posY = e.pageY - tooltipHeight - 10; - - tooltipEl.css({ top: posY, left: posX }); }); - // Prevent the tooltip from showing when hovering over .popup-content - $('.popup-content').on('mouseenter', function (e) { - clearTimeout($('.tooltip').data('timeout')); - $('.tooltip-wrapper').remove(); // Ensure tooltip does not appear - }); + if ($(window).width() < 768) { + $('.dashboard-panel-plugin-list').slideToggle(300); + $('.chatbutton').slideToggle(300); + $('#settings').slideToggle(300); + } } -function fillPresets() { - for (let i = 1; i <= 4; i++) { - let presetText = localStorage.getItem(`preset${i}`); - $(`#preset${i}`).text(presetText); - $(`#preset${i}`).click(function() { - tuneTo(Number(presetText)); +function initTooltips(target = null) { + // Define scope: all tooltips or specific one if target is provided + const tooltips = target ? $(target) : $('.tooltip'); + + // Unbind existing event handlers before rebinding to avoid duplication + tooltips.off('mouseenter mouseleave'); + + tooltips.hover(function () { + if ($(this).closest('.popup-content').length) { + return; + } + + var tooltipText = $(this).data('tooltip'); + var placement = $(this).data('tooltip-placement') || 'top'; // Default to 'top' + + // Clear existing timeouts + $(this).data('timeout', setTimeout(() => { + $('.tooltip-wrapper').remove(); + + var tooltip = $(` +
    +
    ${tooltipText}
    +
    + `); + $('body').append(tooltip); + + var tooltipEl = $('.tooltiptext'); + var tooltipWidth = tooltipEl.outerWidth(); + var tooltipHeight = tooltipEl.outerHeight(); + var targetEl = $(this); + var targetOffset = targetEl.offset(); + var targetWidth = targetEl.outerWidth(); + var targetHeight = targetEl.outerHeight(); + + // Compute position + var posX, posY; + switch (placement) { + case 'bottom': + posX = targetOffset.left + targetWidth / 2 - tooltipWidth / 2; + posY = targetOffset.top + targetHeight + 10; + break; + case 'left': + posX = targetOffset.left - tooltipWidth - 10; + posY = targetOffset.top + targetHeight / 2 - tooltipHeight / 2; + break; + case 'right': + posX = targetOffset.left + targetWidth + 10; + posY = targetOffset.top + targetHeight / 2 - tooltipHeight / 2; + break; + case 'top': + default: + posX = targetOffset.left + targetWidth / 2 - tooltipWidth / 2; + posY = targetOffset.top - tooltipHeight - 10; + break; + } + + // Apply positioning + tooltipEl.css({ top: posY, left: posX, opacity: 1 }); + + }, 300)); + }, function () { + clearTimeout($(this).data('timeout')); + + setTimeout(() => { + $('.tooltip-wrapper').fadeOut(300, function () { + $(this).remove(); + }); + }, 100); + }); + + $('.popup-content').off('mouseenter').on('mouseenter', function () { + clearTimeout($('.tooltip').data('timeout')); + $('.tooltip-wrapper').fadeOut(300, function () { + $(this).remove(); + }); }); } -} \ No newline at end of file + + + function fillPresets() { + for (let i = 1; i <= 4; i++) { + let presetText = localStorage.getItem(`preset${i}`); + $(`#preset${i}-text`).text(presetText); + $(`#preset${i}`).click(function() { + tuneTo(Number(presetText)); + }); + } + } \ No newline at end of file diff --git a/web/js/plugins.js b/web/js/plugins.js new file mode 100644 index 0000000..4487f3e --- /dev/null +++ b/web/js/plugins.js @@ -0,0 +1,61 @@ +function checkScroll() { + let $container = $(".scrollable-container"); + let $leftArrow = $(".scroll-left"); + let $rightArrow = $(".scroll-right"); + + let scrollWidth = $container[0].scrollWidth; + let clientWidth = $container[0].clientWidth; + let scrollLeft = $container.scrollLeft(); + let maxScrollLeft = scrollWidth - clientWidth; + + if (scrollWidth > clientWidth) { + // If scrolling is possible, show arrows + $leftArrow.stop(true, true).fadeIn(200).css("pointer-events", scrollLeft > 0 ? "auto" : "none").fadeTo(200, scrollLeft > 0 ? 1 : 0.2); + $rightArrow.stop(true, true).fadeIn(200).css("pointer-events", scrollLeft < maxScrollLeft ? "auto" : "none").fadeTo(200, scrollLeft < maxScrollLeft ? 1 : 0.2); + } else { + // No scrolling needed, fully hide arrows + $leftArrow.stop(true, true).fadeOut(200); + $rightArrow.stop(true, true).fadeOut(200); + } +} + + + +$(document).ready(function () { + let $container = $(".scrollable-container"); + let $leftArrow = $(".scroll-left"); + let $rightArrow = $(".scroll-right"); + + // Scroll left/right when arrows are clicked + $leftArrow.on("click", function () { + $container.animate({ scrollLeft: "-=100" }, 300); + }); + + $rightArrow.on("click", function () { + $container.animate({ scrollLeft: "+=100" }, 300); + }); + + // Detect scrolling + $container.on("scroll", checkScroll); + + // Run checkScroll on page load to adjust visibility + setTimeout(checkScroll, 100); +}); + +// Function to add buttons dynamically +function addIconToPluginPanel(id, text, iconType, icon, tooltip) { + let $pluginButton = $(` + + `); + + $('.scrollable-container').append($pluginButton); + initTooltips($pluginButton); + + // Recheck scrolling when new buttons are added + setTimeout(checkScroll, 100); +} diff --git a/web/js/settings.js b/web/js/settings.js index 9a3e1fa..b97ff08 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -209,7 +209,7 @@ function setTheme(themeName) { $(':root').css('--color-main-bright', themeColors[1]); $(':root').css('--color-text', themeColors[2]); $(':root').css('--color-text-2', textColor2); - $('#wrapper-outer').css('background-color', backgroundColorWithOpacity); + $('.wrapper-outer').css('background-color', backgroundColorWithOpacity); } } diff --git a/web/js/webserver.js b/web/js/webserver.js index 5170e21..000b5e7 100644 --- a/web/js/webserver.js +++ b/web/js/webserver.js @@ -4,4 +4,5 @@ $.getScript('./js/dropdown.js'); $.getScript('./js/modal.js'); $.getScript('./js/settings.js'); $.getScript('./js/chat.js'); -$.getScript('./js/toast.js'); \ No newline at end of file +$.getScript('./js/toast.js'); +$.getScript('./js/plugins.js'); \ No newline at end of file diff --git a/web/login.ejs b/web/login.ejs index 8a6f5aa..3fda0b3 100644 --- a/web/login.ejs +++ b/web/login.ejs @@ -13,7 +13,7 @@
    -
    +
    diff --git a/web/setup.ejs b/web/setup.ejs index 5c1a3d9..d2f9539 100644 --- a/web/setup.ejs +++ b/web/setup.ejs @@ -13,7 +13,7 @@
    -
    +