No nearby landmarks found.
'; } } catch (error) { console.error('Error fetching Wikipedia data:', error); document.getElementById('landmark-items').innerHTML = 'Error loading landmark data.
'; } } // Function to initialize the radar display function initializeRadar() { const canvas = document.getElementById('radarDisplay'); if (canvas) { radarContext = canvas.getContext('2d'); // Initial draw updateWindage(0, null, 0, 0); } } // Function to update windage on the radar display function updateWindage(vehicleSpeed, vehicleHeading, windSpeed, windDirection) { const canvas = radarContext.canvas; const centerX = canvas.width / 2; const centerY = canvas.height / 2; const radius = Math.min(centerX, centerY) - 8; // Clear canvas with transparent background radarContext.clearRect(0, 0, canvas.width, canvas.height); // Draw circular background radarContext.beginPath(); radarContext.arc(centerX, centerY, radius, 0, 2 * Math.PI); radarContext.strokeStyle = '#666'; radarContext.lineWidth = 1; radarContext.stroke(); // Draw concentric circles with speed labels for (let i = 1; i <= 4; i++) { const currentRadius = (radius * i) / 4; radarContext.beginPath(); radarContext.arc(centerX, centerY, currentRadius, 0, 2 * Math.PI); radarContext.strokeStyle = '#666'; radarContext.setLineDash([2, 2]); radarContext.stroke(); radarContext.setLineDash([]); // Add speed label const speedLabel = Math.round((MAX_SPEED * i) / 4); radarContext.fillStyle = '#666'; radarContext.font = '10px Inter'; radarContext.textAlign = 'right'; radarContext.fillText(speedLabel, centerX - 5, centerY - currentRadius + 12); } // Draw cardinal direction lines radarContext.beginPath(); radarContext.moveTo(centerX, centerY - radius); radarContext.lineTo(centerX, centerY + radius); radarContext.moveTo(centerX - radius, centerY); radarContext.lineTo(centerX + radius, centerY); radarContext.strokeStyle = '#666'; radarContext.stroke(); // Draw direction labels with dark gray background for visibility radarContext.fillStyle = '#666'; radarContext.font = '12px Inter'; radarContext.textAlign = 'center'; radarContext.textBaseline = 'middle'; // Position labels with proper spacing and background const labelOffset = radius - 5; function drawLabel(text, x, y) { const padding = 4; const metrics = radarContext.measureText(text); radarContext.fillStyle = '#666'; radarContext.fillText(text, x, y); } drawLabel('FWD', centerX, centerY - labelOffset); drawLabel('AFT', centerX, centerY + labelOffset); drawLabel('RT', centerX + labelOffset, centerY); drawLabel('LT', centerX - labelOffset, centerY); // Get the Tesla blue color from CSS const teslaBlue = getComputedStyle(document.documentElement).getPropertyValue('--tesla-blue').trim(); // Helper function to draw arrow function drawArrow(fromX, fromY, toX, toY, color, headLength = 9) { const angle = Math.atan2(toY - fromY, toX - fromX); const headAngle = Math.PI / 6; // 30 degrees radarContext.beginPath(); radarContext.moveTo(fromX, fromY); radarContext.lineTo(toX, toY); // Draw the arrow head radarContext.lineTo( toX - headLength * Math.cos(angle - headAngle), toY - headLength * Math.sin(angle - headAngle) ); radarContext.moveTo(toX, toY); radarContext.lineTo( toX - headLength * Math.cos(angle + headAngle), toY - headLength * Math.sin(angle + headAngle) ); radarContext.strokeStyle = color; radarContext.lineWidth = 3; radarContext.stroke(); } // Calculate headwind and crosswind components and display on radar let headWind = null; let crossWind = null; if (vehicleHeading && windDirection) { // Threshold for meaningful motion const windAngle = windDirection - vehicleHeading; // car frame const windAngleRad = (90 - windAngle) * Math.PI / 180; // Wind vector components in global frame const windX = windSpeed * Math.cos(windAngleRad); const windY = windSpeed * Math.sin(windAngleRad); // Sum the vectors to get relative wind (for radar plot) const relativeWindX = windX; const relativeWindY = windY; headWind = -windY; // Will be negative if a tailwind crossWind = windX; // Will be positive if from the left const windScale = radius / MAX_SPEED; const relativeWindXPlot = centerX + relativeWindX * windScale; const relativeWindYPlot = centerY - relativeWindY * windScale; drawArrow(centerX, centerY, relativeWindXPlot, relativeWindYPlot, teslaBlue); } // Update the wind component displays with proper units if (headWind !== null) { if (!settings || settings["imperial-units"]) { document.getElementById('headwind').innerText = Math.abs(Math.round(headWind)); } else { // Convert mph to m/s (1 mph ≈ 0.44704 m/s) document.getElementById('headwind').innerText = Math.abs(Math.round(headWind * 0.44704)); } document.getElementById('headwind-arrow').innerHTML = (headWind > 0 ? '▼' : '▲'); // down/up filled triangles // Change the label to TAILWIND when headWind is negative and use appropriate units if (!settings || settings["imperial-units"]) { document.getElementById('headwind-label').innerText = (headWind < 0) ? "TAILWIND (MPH)" : "HEADWIND (MPH)"; } else { document.getElementById('headwind-label').innerText = (headWind < 0) ? "TAILWIND (M/S)" : "HEADWIND (M/S)"; } } else { document.getElementById('headwind').innerText = '--'; document.getElementById('headwind-arrow').innerHTML = ''; // Set label with appropriate units if (!settings || settings["imperial-units"]) { document.getElementById('headwind-label').innerText = "HEADWIND (MPH)"; } else { document.getElementById('headwind-label').innerText = "HEADWIND (M/S)"; } } if (crossWind !== null) { if (!settings || settings["imperial-units"]) { document.getElementById('crosswind').innerText = Math.abs(Math.round(crossWind)); } else { // Convert mph to m/s document.getElementById('crosswind').innerText = Math.abs(Math.round(crossWind * 0.44704)); } document.getElementById('crosswind-arrow').innerHTML = (crossWind >= 0 ? '▶' : '◀'); // right/left triangles } else { document.getElementById('crosswind').innerText = '--'; document.getElementById('crosswind-arrow').innerHTML = ''; } // Set label with appropriate units if (!settings || settings["imperial-units"]) { document.getElementById('crosswind-label').innerText = "CROSSWIND (MPH)"; } else { document.getElementById('crosswind-label').innerText = "CROSSWIND (M/S)"; } } // Function to update location-dependent data async function updateLocationData(lat, long) { console.log('Updating location dependent data for (', lat, ', ', long, ')'); neverUpdatedLocation = false; // Fire off API requests for external data // updateTimeZone(lat, long); fetchCityData(lat, long); // Update connectivity data iff the Network section is visible // const networkSection = document.getElementById("network"); // if (networkSection.style.display === "block") { // console.log('Updating connectivity data...'); // updateNetworkInfo(); // } // Update Wikipedia data iff the Landmarks section is visible const locationSection = document.getElementById("landmarks"); if (locationSection.style.display === "block") { console.log('Updating Wikipedia data...'); fetchLandmarkData(lat, long); } } // Function to determine if short-range data should be updated function shouldUpdateShortRangeData() { if (neverUpdatedLocation || !lastUpdateLat || !lastUpdateLong) { return true; } const now = Date.now(); const timeSinceLastUpdate = (now - lastUpdate) / (1000 * 60); // Convert to minutes const distance = calculateDistance(lat, long, lastUpdateLat, lastUpdateLong); return distance >= UPDATE_DISTANCE_THRESHOLD || timeSinceLastUpdate >= UPDATE_TIME_THRESHOLD; } // Function to determine if long-range data should be updated function shouldUpdateLongRangeData() { // Check if we've never updated weather data if (lastWxUpdate === 0 || lastWxUpdateLat === null || lastWxUpdateLong === null) { return true; } // Check time threshold using WX_TIME_THRESHOLD constant const now = Date.now(); const timeSinceLastUpdate = now - lastWxUpdate; if (timeSinceLastUpdate >= WX_TIME_THRESHOLD * 60 * 1000) { // Convert minutes to milliseconds return true; } // Check distance threshold using WX_DISTANCE_THRESHOLD constant if (lat !== null && long !== null) { const distance = calculateDistance(lat, long, lastWxUpdateLat, lastWxUpdateLong); if (distance >= WX_DISTANCE_THRESHOLD) { // Use constant for meters return true; } } // No need to update weather data return false; } // Function to handle position updates from GPS function handlePositionUpdate(position) { lat = position.coords.latitude; long = position.coords.longitude; alt = position.coords.altitude; acc = position.coords.accuracy; speed = position.coords.speed / 0.44704; // Convert m/s to mph if (position.coords.heading) { lastKnownHeading = position.coords.heading; } // Update GPS status indicator with color gradient based on accuracy const gpsStatusElement = document.getElementById('gps-status'); if (gpsStatusElement) { if (lat === null || long === null) { // Use CSS variable for unavailable GPS gpsStatusElement.style.color = 'var(--status-unavailable)'; gpsStatusElement.title = 'GPS Unavailable'; } else { // Interpolate between yellow and green based on accuracy const maxAccuracy = 50; // Yellow threshold const minAccuracy = 1; // Green threshold // Clamp accuracy between min and max thresholds const clampedAcc = Math.min(Math.max(acc, minAccuracy), maxAccuracy); // Calculate interpolation factor (0 = yellow, 1 = green) const factor = 1 - (clampedAcc - minAccuracy) / (maxAccuracy - minAccuracy); if (factor < 0.5) { gpsStatusElement.style.color = 'var(--status-poor)'; } else { gpsStatusElement.style.color = 'var(--status-good)'; } gpsStatusElement.title = `GPS Accuracy: ${Math.round(acc)}m`; } } // Update radar display with current speed and heading if nav section is visible const navigationSection = document.getElementById("navigation"); if (navigationSection.style.display === "block") { // Update heading displays if (lastKnownHeading) { document.getElementById('heading').innerText = Math.round(lastKnownHeading) + '°'; if (forecastDataPrem && forecastDataPrem.current) { const windSpeedMPH = Math.min((forecastDataPrem.current.wind_speed * 2.237), MAX_SPEED); const windDir = forecastDataPrem.current.wind_deg; updateWindage(speed, lastKnownHeading, windSpeedMPH, windDir); } else { updateWindage(speed, lastKnownHeading, 0, 0); } } else { document.getElementById('heading').innerText = '--'; updateWindage(0, null, 0, 0); } // Update display values with proper units if (alt !== null) { if (!settings || settings["imperial-units"]) { // Convert meters to feet document.getElementById('altitude').innerText = Math.round(alt * 3.28084); document.getElementById('altitude-unit').innerText = 'FT'; } else { document.getElementById('altitude').innerText = Math.round(alt); document.getElementById('altitude-unit').innerText = 'M'; } } else { document.getElementById('altitude').innerText = '--'; } document.getElementById('accuracy').innerText = acc ? Math.round(acc) + ' m' : '--'; // Update headwind/crosswind labels if (!settings || settings["imperial-units"]) { document.getElementById('headwind-label').innerText = document.getElementById('headwind-label').innerText.replace("(MPH)", "(MPH)"); document.querySelector('.stat-box:nth-child(4) .stat-label').innerText = document.querySelector('.stat-box:nth-child(4) .stat-label').innerText.replace("(MPH)", "(MPH)"); } else { document.getElementById('headwind-label').innerText = document.getElementById('headwind-label').innerText.replace("(MPH)", "(M/S)"); document.querySelector('.stat-box:nth-child(4) .stat-label').innerText = document.querySelector('.stat-box:nth-child(4) .stat-label').innerText.replace("(MPH)", "(M/S)"); } } // Short distance updates if (shouldUpdateShortRangeData()) { updateLocationData(lat, long); fetchPremiumWeatherData(lat, long); lastUpdateLat = lat; lastUpdateLong = long; lastUpdate = Date.now(); } // Long distance updates if (shouldUpdateLongRangeData()) { updateTimeZone(lat, long); lastWxUpdateLat = lat; lastWxUpdateLong = long; lastWxUpdate = Date.now(); } } // Function to update GPS data function updateGPS() { if (!testMode) { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(handlePositionUpdate); } else { console.log('Geolocation is not supported by this browser.'); return false; } } else { // testing handlePositionUpdate(positionSimulator.getPosition()); } return true; } // Function to throttle GPS updates function throttledUpdateGPS() { const now = Date.now(); if (now - lastGPSUpdate >= MIN_GPS_UPDATE_INTERVAL) { lastGPSUpdate = now; updateGPS(); } else { console.log('Skipping rapid GPS update'); } } // Function to start the GPS updates function startGPSUpdates() { if (!gpsIntervalId) { if (updateGPS()) { // Call immediately and check if browser supports gpsIntervalId = setInterval(throttledUpdateGPS, 1000 * LATLON_UPDATE_INTERVAL); console.log('GPS updates started'); } } } // Function to stop the GPS updates function stopGPSUpdates() { if (gpsIntervalId) { clearInterval(gpsIntervalId); gpsIntervalId = null; console.log('GPS updates paused'); } } // Check for NOTE file and display if present function updateServerNote() { fetch('NOTE', { cache: 'no-store' }) .then(response => { if (!response.ok) { throw new Error('File not found.'); } return response.text(); }) .then(content => { // Sanitize the content to prevent XSS const sanitizedContent = document.createElement('div'); sanitizedContent.textContent = content; // Update the note paragraph with the sanitized content in italic const noteElement = document.getElementById('note'); noteElement.innerHTML = sanitizedContent.innerHTML; // Show the announcement section const announcementSection = document.getElementById('announcement'); if (announcementSection) { announcementSection.style.display = 'block'; } // Add notification dot to About section if it's not the current section const aboutSection = document.getElementById('about'); if (aboutSection && aboutSection.style.display !== 'block') { const aboutButton = document.querySelector('.section-button[onclick="showSection(\'about\')"]'); if (aboutButton) { aboutButton.classList.add('has-notification'); } } }) .catch(error => { console.log('No NOTE file available.'); // Ensure the announcement section is hidden const announcementSection = document.getElementById('announcement'); if (announcementSection) { announcementSection.style.display = 'none'; } }); } // Show git version from vers.php function updateVersion() { const versionElement = document.getElementById('version'); if (versionElement) { fetch('vers.php') .then(response => response.json()) .then(data => { const versionText = `${data.branch || 'unknown'}-${data.commit || 'unknown'}`; versionElement.innerHTML = versionText; }) .catch(error => { console.error('Error fetching version:', error); versionElement.innerHTML = 'Error loading version'; }); } } window.updateMapFrame = function () { // Normal mode - ensure iframe is visible and test mode message is hidden const teslaWazeContainer = document.querySelector('.teslawaze-container'); const iframe = teslaWazeContainer.querySelector('iframe'); let testModeMsg = teslaWazeContainer.querySelector('.test-mode-message'); if (!testMode) { if (settings["map-choice"] === 'waze') { srcUpdate("teslawaze", "https://teslawaze.azurewebsites.net/"); } else { srcUpdate("teslawaze", "https://abetterrouteplanner.com/"); } iframe.style.display = ''; if (testModeMsg) testModeMsg.style.display = 'none'; } else { // In test mode, replace TeslaWaze iframe with "TESTING MODE" message iframe.src = ""; iframe.style.display = 'none'; // Check if our test mode message already exists if (!testModeMsg) { // Create and add the test mode message testModeMsg = document.createElement('div'); testModeMsg.className = 'test-mode-message'; testModeMsg.style.cssText = 'display: flex; justify-content: center; align-items: center; height: 100%; font-size: 32px; font-weight: bold;'; testModeMsg.textContent = 'TESTING MODE'; teslaWazeContainer.appendChild(testModeMsg); } else { testModeMsg.style.display = 'flex'; } } } // Function to load an external URL in a new tab or frame window.loadExternalUrl = function (url, inFrame = false) { // Open external links in a new tab if (!inFrame) { window.open(url, '_blank'); return; } // Hide all sections first const sections = document.querySelectorAll('.section'); sections.forEach(section => { section.style.display = 'none'; }); // Get the external-site container const externalSite = document.getElementById('external-site'); // Clear any existing content externalSite.innerHTML = ''; // Create and load iframe const iframe = document.createElement('iframe'); iframe.setAttribute('allow', 'geolocation; fullscreen'); iframe.src = url; externalSite.appendChild(iframe); // Show the external site container externalSite.style.display = 'block'; // Flag the right frame as being in external mode const rightFrame = document.getElementById('rightFrame'); rightFrame.classList.add('external'); // Deactivate current section button // const activeButton = document.querySelector('.section-button.active'); // if (activeButton) { // activeButton.classList.remove('active'); // } } // Show a specific section and update URL - defined directly on window object window.showSection = function (sectionId) { const rightFrame = document.getElementById('rightFrame'); // Check if we're actually going anywhere if (currentSection === sectionId && !rightFrame.classList.contains('external')) { console.log(`Already in section: ${sectionId}`); return; } // Log the clicked section console.log(`Showing section: ${sectionId}`); // Update URL without page reload const url = new URL(window.location); url.searchParams.set('section', sectionId); window.history.pushState({}, '', url); // Shutdown external site if there is one const externalSite = document.getElementById('external-site'); if (rightFrame.classList.contains('external')) { // Hide the external site container externalSite.style.display = 'none'; // Clear any existing iframe content to prevent resource usage externalSite.innerHTML = ''; // Remove external mode flag rightFrame.classList.remove('external'); } // If we're leaving settings, handle any rss feed changes if (currentSection === 'settings') { leaveSettings(); } // Clear "new" markers from news items and clear unread flags from data if (currentSection === 'news') { const newNewsItems = document.querySelectorAll('.news-new'); newNewsItems.forEach(item => { item.classList.remove('news-new'); }); markAllNewsAsRead(); } // If switching to news section, clear the notification dot and start time updates if (sectionId === 'news') { const newsButton = document.querySelector('.section-button[onclick="showSection(\'news\')"]'); if (newsButton) { newsButton.classList.remove('has-notification'); } // Start the timer that updates the "time ago" displays startNewsTimeUpdates(); } else if (currentSection === 'news') { // If we're leaving the news section, stop the time updates stopNewsTimeUpdates(); } // If switching to about section, clear the notification dot if (sectionId === 'about') { const aboutButton = document.querySelector('.section-button[onclick="showSection(\'about\')"]'); if (aboutButton) { aboutButton.classList.remove('has-notification'); } } // Satellite section // TODO: This stuff should either be in wx.js or SAT_URLS moved here. if (sectionId === 'satellite') { // Load weather image when satellite section is shown const weatherImage = document.getElementById('weather-image'); weatherImage.src = SAT_URLS.latest; } else { // Remove weather img src to force reload when switching back const weatherImage = document.getElementById('weather-image'); if (weatherImage) { weatherImage.src = ''; } } // Update network info if the network section is visible if (sectionId === 'network') { if (!networkInfoUpdated) { updateNetworkInfo(); networkInfoUpdated = true; } updatePingChart(true); // with animation } // Update Wikipedia data if the landmarks section is visible if (sectionId === 'landmarks') { if (lat !== null && long !== null) { fetchLandmarkData(lat, long); } else { console.log('Location not available for Wikipedia data.'); } } // Hide all sections first const sections = document.querySelectorAll('.section'); sections.forEach(section => { section.style.display = 'none'; }); // Make sure external-site is hidden externalSite.style.display = 'none'; // Show the selected section const section = document.getElementById(sectionId); if (section) { section.style.display = 'block'; } // Deactivate all buttons const buttons = document.querySelectorAll('.section-button'); buttons.forEach(button => { button.classList.remove('active'); }); // Activate the clicked button const button = document.querySelector(`.section-button[onclick="showSection('${sectionId}')"]`); if (button) { button.classList.add('active'); } // Update the current section variable currentSection = sectionId; }; // ***** Main code ***** // Update link click event listener document.addEventListener('click', function (e) { if (e.target.tagName === 'A' && !e.target.closest('.section-buttons')) { e.preventDefault(); const inFrame = e.target.hasAttribute('data-frame'); loadExternalUrl(e.target.href, inFrame); } }); // Handle browser back/forward buttons window.addEventListener('popstate', () => { showSection(getInitialSection()); }); // Handle page visibility changes document.addEventListener('visibilitychange', () => { if (document.hidden) { stopGPSUpdates(); pauseNewsUpdates(); pausePingTest(); } else { startGPSUpdates(); resumeNewsUpdates(); resumePingTest(); } }); // Event listeners and initialization after DOM content is loaded document.addEventListener('DOMContentLoaded', async function () { // Log console.log('DOM fully loaded and parsed...'); // Attempt login from URL parameter or cookie await attemptLogin(); // Check for NOTE file and display if present updateServerNote(); // Initialize radar display initializeRadar(); // Start location services startGPSUpdates(); // Start news updates resumeNewsUpdates(); // Begin network sensing startPingTest(); // Get version from vers.php asyncly updateVersion(); // Add event listeners for login modal document.getElementById('login-cancel').addEventListener('click', closeLoginModal); document.getElementById('login-submit').addEventListener('click', handleLogin); // Handle Enter key in login form document.getElementById('user-id').addEventListener('keyup', function (event) { if (event.key === 'Enter') { handleLogin(); } }); // Show the initial section from URL parameter const urlParams = new URLSearchParams(window.location.search); const initialSection = urlParams.get('section') || 'news'; showSection(initialSection); }); PKW/w w PK- Z cloud.svg SV PKP P PK- $Z common.js SV // Imports import { settings } from './settings.js'; // Global variables const GEONAMES_USERNAME = 'birgefuller'; let locationTimeZone = browserTimeZone(); let testMode = false; // Set to true if test parameter exists // Exports export { locationTimeZone, testMode, GEONAMES_USERNAME } // Set time zone based on location export async function updateTimeZone(lat, long) { try { const response = await fetch(`https://secure.geonames.org/timezoneJSON?lat=${lat}&lng=${long}&username=${GEONAMES_USERNAME}`); const tzData = await response.json(); if (!tzData || !tzData.timezoneId) { throw new Error('Timezone not returned from server.'); } console.log('Timezone: ', tzData.timezoneId); return tzData.timezoneId; } catch (error) { console.error('Error fetching timezone: ', error); const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; console.log('Fallback timezone: ', tz); return tz; } } // Update element with a change-dependent highlight effect export function highlightUpdate(id, content = null) { const element = document.getElementById(id); if (content !== null) { if (element.innerHTML === content) { return; // Exit if content is the same } element.innerHTML = content; } const highlightColor = getComputedStyle(document.documentElement).getPropertyValue('--tesla-blue').trim(); // const originalFontWeight = getComputedStyle(element).fontWeight; element.style.transition = 'color 0.5s, font-weight 0.5s'; element.style.color = highlightColor; // element.style.fontWeight = '800'; setTimeout(() => { element.style.transition = 'color 2s, font-weight 2s'; element.style.color = ''; // Reset to default color // element.style.fontWeight = ''; // Reset to original font weight }, 2000); } // Update src of element only if it needs to change to avoid reloads export function srcUpdate(id, url) { const element = document.getElementById(id); const currentUrl = element.src; console.log('current src:', currentUrl); console.log('new src:', url); if (!(url === currentUrl)) { element.src = url; console.log('Updating src for', id); } } // Helper function to format time according to user settings export function formatTime(date, options = {}) { // Default options const defaultOptions = { hour: 'numeric', minute: '2-digit', timeZone: locationTimeZone }; // Merge provided options with defaults const timeOptions = {...defaultOptions, ...options}; // Check if 24-hour format is enabled in settings if (settings && settings['24-hour-time']) { timeOptions.hour12 = false; } return date.toLocaleTimeString('en-US', timeOptions); } // Return time zone based on browser settings function browserTimeZone() { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; console.log('Browser timezone: ', tz); return tz; } // ***** Initialization ***** // URL parameters const urlParams = new URLSearchParams(window.location.search); const testParam = urlParams.get('test'); testMode = testParam === 'true'; if (testMode) { console.log('##### TEST MODE #####'); } PK,M PK- G.Z favicon.svg SV PK= PK- G.Z git_info.php SV 'unknown', 'branch' => null, 'tag' => null ]; // Get commit hash and branch name from .git/HEAD $gitHeadFile = __DIR__ . '/.git/HEAD'; if (file_exists($gitHeadFile)) { $headContent = trim(file_get_contents($gitHeadFile)); if (strpos($headContent, 'ref:') === 0) { // HEAD points to a branch $branchName = str_replace('ref: refs/heads/', '', $headContent); $gitInfo['branch'] = $branchName; // Resolve branch to commit hash $branchRefFile = __DIR__ . '/.git/refs/heads/' . $branchName; if (file_exists($branchRefFile)) { $gitInfo['commit'] = substr(trim(file_get_contents($branchRefFile)), 0, 8); } } else { // Detached HEAD state, HEAD contains the commit hash $gitInfo['commit'] = substr($headContent, 0, 8); // Truncate to 8 digits } } // Check for tag name $gitTagsDir = __DIR__ . '/.git/refs/tags/'; if (is_dir($gitTagsDir)) { $tags = scandir($gitTagsDir); foreach ($tags as $tag) { if ($tag === '.' || $tag === '..') { continue; } $tagRefFile = $gitTagsDir . $tag; if (file_exists($tagRefFile) && substr(trim(file_get_contents($tagRefFile)), 0, 8) === $gitInfo['commit']) { $gitInfo['tag'] = $tag; break; } } } return $gitInfo; } PKfQ PK- G.Z icons.svg SV PK*mc/ PK- jZ index.html SVHeadlines...
These may not all work well on the Tesla browser, so they are here for people to test. Feel free to provide feedback to feedback@teslas.cloud.
This service was created by Jonathan Birge and Alex Birge as an educational father-son project. It is in no way affiliated with Tesla, Inc. No warranty is made as to its utility for any purpose.
For questions, issues, or feedback: email feedback@teslas.cloud or submit an issue to the GitHub repository.
Apologies to those outside of the US; right now some functionality is limited to CONUS.
Thanks to whomever made the fantastic "TeslaWaze" site, one of the embedded options in the dashboard. Thank you, as well, to the friendly folks on Tesla Owners Online, who gave considerable helpful feedback and support on early versions.
--
No headlines available
'; } } catch (error) { console.error('Error fetching news:', error); console.log('Error fetching news:', error); const newsContainer = document.getElementById('newsHeadlines'); // Make sure to remove the spinner even in case of an error if (newsContainer) { const spinnerContainer = newsContainer.querySelector('.spinner-container'); if (spinnerContainer) { spinnerContainer.remove(); } newsContainer.innerHTML = 'Error loading headlines
'; } } // Update the visibility of share buttons setShareButtonsVisibility(); } // Set visibility of the share buttons based on settings export function setShareButtonsVisibility() { const shareButtons = document.querySelectorAll('.share-icon'); shareButtons.forEach(button => { if (settings["news-forwarding"]) { button.style.display = 'block'; } else { button.style.display = 'none'; } }); } // Updates all news timestamp displays on the page export function updateNewsTimeDisplays() { const timeElements = document.querySelectorAll('.news-time[data-timestamp]'); timeElements.forEach(element => { const timestamp = parseInt(element.getAttribute('data-timestamp')); if (!isNaN(timestamp)) { element.textContent = generateTimeAgoText(timestamp); } }); } // Start the interval that updates time ago displays export function startNewsTimeUpdates() { console.log('Starting news time updates'); // Clear any existing interval first if (newsTimeUpdateInterval) { clearInterval(newsTimeUpdateInterval); } // Update immediately updateNewsTimeDisplays(); // Then set up interval to update every second newsTimeUpdateInterval = setInterval(updateNewsTimeDisplays, 5000); } // Stop the interval that updates time ago displays export function stopNewsTimeUpdates() { console.log('Stopping news time updates'); if (newsTimeUpdateInterval) { clearInterval(newsTimeUpdateInterval); newsTimeUpdateInterval = null; } } // Mark all current news items as read export function markAllNewsAsRead() { console.log('Marking all news as read'); if (newsItems) { newsItems.forEach(item => { item.isUnread = false; }); } } // Utility function to generate "time ago" text from a timestamp function generateTimeAgoText(timestamp) { const now = new Date(); const itemDate = new Date(timestamp * 1000); const timeDifference = Math.floor((now - itemDate) / 1000); // Difference in seconds if (timeDifference < 60) { return `${timeDifference} seconds ago`; } else if (timeDifference < 7200) { const minutes = Math.floor(timeDifference / 60); return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; } else if (timeDifference < 86400) { const hours = Math.floor(timeDifference / 3600); return `${hours} hour${hours > 1 ? 's' : ''} ago`; } else { const days = Math.floor(timeDifference / 86400); const remainingSeconds = timeDifference % 86400; const hours = Math.floor(remainingSeconds / 3600); return `${days} day${days > 1 ? 's' : ''} and ${hours} hour${hours > 1 ? 's' : ''} ago`; } } // Generate unique IDs for news items function genItemID(item) { // Create a unique ID based on title and source return `${item.source}-${item.title.substring(0, 40)}`; } // Takes news item and generates HTML for it function generateHTMLforItem(item) { // If the item is unread, add a class to highlight it let classList = null; if (item.isUnread) { classList = 'news-item news-new'; } else { classList = 'news-item'; } // Extract domain for favicon either from the item.icon or from item.link if available let faviconUrl = ''; if (item.icon && typeof item.icon === 'string' && item.icon.trim() !== '') { // Use the domain from the icon key faviconUrl = `https://www.google.com/s2/favicons?domain=${item.icon}&sz=32`; } else { try { const url = new URL(item.link); faviconUrl = `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; } catch (e) { console.error('Error parsing URL for favicon:', e); } } return `${source}
${title}Sent from teslas.cloud
`; // Create the subject line const subject = `[teslas.cloud] ${title}`; // Communicate with the forwarding server try { const response = await fetch('share.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to, html, subject }) }); if (response.ok) { const alertBox = document.createElement('div'); alertBox.textContent = 'Article shared successfully'; alertBox.style.position = 'fixed'; alertBox.style.top = '20px'; alertBox.style.left = '50%'; alertBox.style.transform = 'translateX(-50%)'; alertBox.style.backgroundColor = "rgb(15, 181, 21) "; alertBox.style.color = 'white'; alertBox.style.padding = '15px'; alertBox.style.borderRadius = '5px'; alertBox.style.boxShadow = '0 2px 5px rgba(0, 0, 0, 0.3)'; alertBox.style.zIndex = '9999'; document.body.appendChild(alertBox); setTimeout(() => { document.body.removeChild(alertBox); }, 5000); } else { const errorText = await response.text(); alert('Failed to share article: ' + errorText); } } catch (err) { alert('Error sharing article: ' + err); } } // Pauses the automatic news updates window.pauseNewsUpdates = function () { if (newsUpdateInterval) { clearInterval(newsUpdateInterval); newsUpdateInterval = null; console.log('News updates paused'); } } // Resumes the automatic news updates if paused window.resumeNewsUpdates = function () { if (!newsUpdateInterval) { updateNews(); // Call immediately newsUpdateInterval = setInterval(updateNews, 60000 * NEWS_REFRESH_INTERVAL); console.log('News updates resumed'); } } PK4A0 0 PK- G.Z openwx_proxy.php SV $value) { $_ENV[$key] = $value; } } else { error_log("Failed to parse .env file: " . json_last_error_msg()); } } else { error_log(".env file not found at $envFilePath"); } // Check if API key is set if (!isset($_ENV['OPENWX_KEY'])) { http_response_code(500); echo json_encode(['error' => 'API key not found in .env file']); exit; } // Get query parameters $queryParams = $_GET; // Add API key to query parameters $queryParams['appid'] = $_ENV['OPENWX_KEY']; // Get the API endpoint path from the URL path info $pathInfo = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : ''; if (empty($pathInfo)) { http_response_code(400); echo json_encode(['error' => 'No API endpoint specified in path']); exit; } // Remove leading slash if present $pathInfo = ltrim($pathInfo, '/'); // Build the proxied URL $baseUrl = 'https://api.openweathermap.org/'; $proxiedUrl = $baseUrl . $pathInfo . '?' . http_build_query($queryParams); // Initialize cURL $ch = curl_init($proxiedUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HEADER, false); // Execute cURL request $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // Set HTTP response code and output the response http_response_code($httpCode); header('Content-Type: application/json'); echo $response; PKNP P PK- G.Z ping.php SV $value) { $_ENV[$key] = $value; } } else { logMessage("Failed to parse .env file: " . json_last_error_msg(), "ERROR"); http_response_code(500); header('Content-Type: text/plain'); echo "Error parsing .env file."; exit; } } else { logMessage(".env file not found at $envFilePath", "WARNING"); http_response_code(500); header('Content-Type: text/plain'); echo "Configuration file not found."; exit; } // SQL database configuration $dbName = $_ENV['SQL_DB_NAME'] ?? 'teslacloud'; $dbHost = $_ENV['SQL_HOST'] ?? null; $dbUser = $_ENV['SQL_USER'] ?? null; $dbPass = $_ENV['SQL_PASS'] ?? null; $dbPort = $_ENV['SQL_PORT'] ?? '3306'; // Get client IP address $clientIP = $_SERVER['REMOTE_ADDR']; if (isset($_SERVER['HTTP_X_FORWARDED_FOR']) && filter_var($_SERVER['HTTP_X_FORWARDED_FOR'], FILTER_VALIDATE_IP)) { $clientIP = $_SERVER['HTTP_X_FORWARDED_FOR']; } // Establish database connection if ($dbHost && $dbName && $dbUser) { try { $dsn = "mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; $dbConnection = new PDO($dsn, $dbUser, $dbPass, $options); // Check if the ping_data table exists, create it if not $tableCheck = $dbConnection->query("SHOW TABLES LIKE 'ping_data'"); if ($tableCheck->rowCount() == 0) { $sql = "CREATE TABLE ping_data ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, latitude DOUBLE NULL, longitude DOUBLE NULL, altitude DOUBLE NULL, ip_address VARCHAR(45) NULL, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )"; $dbConnection->exec($sql); logMessage("Created ping_data table"); } // Get data from POST request $userId = $_POST['user_id'] ?? 'anonymous'; $latitude = isset($_POST['latitude']) ? (double)$_POST['latitude'] : null; $longitude = isset($_POST['longitude']) ? (double)$_POST['longitude'] : null; $altitude = isset($_POST['altitude']) ? (double)$_POST['altitude'] : null; $pingTime = isset($_POST['ping']) ? (double)$_POST['ping'] : null; // Log the ping data to database $stmt = $dbConnection->prepare("INSERT INTO ping_data (user_id, latitude, longitude, altitude, ip_address, ping_time) VALUES (?, ?, ?, ?, ?, ?)"); $stmt->execute([$userId, $latitude, $longitude, $altitude, $clientIP, $pingTime]); // Respond with 200 OK header('Content-Type: text/plain'); echo "Ping logged successfully."; logMessage("Logged ping from user: " . $userId . ", IP: " . $clientIP); } catch (PDOException $e) { logMessage("Database error: " . $e->getMessage(), "ERROR"); http_response_code(500); header('Content-Type: text/plain'); echo "Database error: " . $e->getMessage(); exit; } } else { logMessage("Missing database configuration", "WARNING"); http_response_code(500); header('Content-Type: text/plain'); echo "Database configuration is missing."; exit; } // Simple logging function function logMessage($message, $level = 'INFO') { global $logFile; $timestamp = date('Y-m-d H:i:s'); $formattedMessage = "[$timestamp] [$level] $message" . PHP_EOL; file_put_contents($logFile, $formattedMessage, FILE_APPEND); } PKV- PK- %Z rss.php SV ['url' => 'https://feeds.content.dowjones.io/public/rss/RSSWorldNews', 'cache' => 5], 'nyt' => ['url' => 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', 'cache' => 5], 'wapo' => ['url' => 'https://www.washingtonpost.com/arcio/rss/category/politics/', 'cache' => 15], 'latimes' => ['url' => 'https://www.latimes.com/rss2.0.xml', 'cache' => 15], 'bos' => ['url' => 'https://www.boston.com/tag/local-news/feed', 'cache' => 15], 'den' => ['url' => 'https://www.denverpost.com/feed/', 'cache' => 15], 'chi' => ['url' => 'https://www.chicagotribune.com/news/feed/', 'cache' => 15], 'bbc' => ['url' => 'http://feeds.bbci.co.uk/news/world/rss.xml', 'cache' => 15], 'lemonde' => ['url' => 'https://www.lemonde.fr/rss/une.xml', 'cache' => 60], 'bloomberg' => ['url' => 'https://feeds.bloomberg.com/news.rss', 'cache' => 15], 'economist' => ['url' => 'https://www.economist.com/latest/rss.xml', 'cache' => 60], 'cnn' => ['url' => 'https://openrss.org/www.cnn.com', 'cache' => 15, 'icon' => 'https://www.cnn.com/'], 'ap' => ['url' => 'https://news.google.com/rss/search?q=when:24h+allinurl:apnews.com&hl=en-US&gl=US&ceid=US:en', 'cache' => 30, 'icon' => 'https://apnews.com/'], 'notateslaapp' => ['url' => 'https://www.notateslaapp.com/rss', 'cache' => 30], 'teslarati' => ['url' => 'https://www.teslarati.com/feed/', 'cache' => 30], 'insideevs' => ['url' => 'https://insideevs.com/rss/articles/all/', 'cache' => 30], 'thedrive' => ['url' => 'https://www.thedrive.com/feed', 'cache' => 30], 'caranddriver' => ['url' => 'https://www.caranddriver.com/rss/all.xml/', 'cache' => 30], 'techcrunch' => ['url' => 'https://techcrunch.com/feed/', 'cache' => 30], 'arstechnica' => ['url' => 'https://feeds.arstechnica.com/arstechnica/index', 'cache' => 30], 'engadget' => ['url' => 'https://www.engadget.com/rss.xml', 'cache' => 30], 'gizmodo' => ['url' => 'https://gizmodo.com/rss', 'cache' => 30], 'theverge' => ['url' => 'https://www.theverge.com/rss/index.xml', 'cache' => 30], 'wired' => ['url' => 'https://www.wired.com/feed/rss', 'cache' => 30], 'spacenews' => ['url' => 'https://spacenews.com/feed/', 'cache' => 30], 'defensenews' => ['url' => 'https://www.defensenews.com/arc/outboundfeeds/rss/?outputType=xml', 'cache' => 30], 'aviationweek' => ['url' => 'https://aviationweek.com/awn/rss-feed-by-content-source', 'cache' => 30] ]; // Set up error logging ini_set('log_errors', 1); ini_set('error_log', '/tmp/rss-php-errors.log'); // Custom error handler to capture all types of errors set_error_handler(function($errno, $errstr, $errfile, $errline) { $message = date('[Y-m-d H:i:s] ') . "Error ($errno): $errstr in $errfile on line $errline\n"; error_log($message); return false; // Let PHP handle the error as well }); // Register shutdown function to catch fatal errors register_shutdown_function(function() { $error = error_get_last(); if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) { $message = date('[Y-m-d H:i:s] ') . "FATAL Error: {$error['message']} in {$error['file']} on line {$error['line']}\n"; error_log($message); } }); // Set the content type and add headers to prevent caching header('Cache-Control: no-cache, no-store, must-revalidate'); header('Expires: 0'); header('Content-Type: application/json'); // Check if we're receiving a POST request with included feeds $includedFeeds = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Get the request body $requestBody = file_get_contents('php://input'); $requestData = json_decode($requestBody, true); // Check if includedFeeds is set in the request if (isset($requestData['includedFeeds']) && is_array($requestData['includedFeeds'])) { $includedFeeds = $requestData['includedFeeds']; logMessage("Received included feeds: " . implode(', ', $includedFeeds)); } } // Load timestamp data if it exists $feedTimestamps = []; if (file_exists($cacheTimestampFile)) { $feedTimestamps = json_decode(file_get_contents($cacheTimestampFile), true); if (!is_array($feedTimestamps)) { $feedTimestamps = []; } } // Determine which feeds to process $requestedFeeds = empty($includedFeeds) ? array_keys($feeds) : $includedFeeds; // Collect all items $allItems = []; $currentTime = time(); $updatedTimestamps = false; foreach ($requestedFeeds as $source) { if (!isset($feeds[$source])) continue; $feedData = $feeds[$source]; $cacheFile = "{$cacheDir}/rss_cache_{$source}_{$version}.json"; $cacheDurationSeconds = $feedData['cache'] * 60; $lastUpdated = isset($feedTimestamps[$source]) ? $feedTimestamps[$source] : 0; $useCache = false; if (!$forceReload && file_exists($cacheFile) && ($currentTime - $lastUpdated) <= $cacheDurationSeconds) { // Use cache $cachedItems = json_decode(file_get_contents($cacheFile), true); if (is_array($cachedItems)) { $allItems = array_merge($allItems, $cachedItems); logMessage("Loaded {$source} from cache."); $useCache = true; } } if (!$useCache) { // Fetch and cache $xml = $useSerialFetch ? fetchRSS($feedData['url']) : fetchRSS($feedData['url']); // Only one at a time now if ($xml !== false) { $items = parseRSS($xml, $source); file_put_contents($cacheFile, json_encode($items)); $feedTimestamps[$source] = $currentTime; $updatedTimestamps = true; $allItems = array_merge($allItems, $items); logMessage("Fetched {$source} from internet and updated cache."); } else { logMessage("Failed to fetch {$source} from internet."); } } } // Update timestamps file if needed if ($updatedTimestamps) { file_put_contents($cacheTimestampFile, json_encode($feedTimestamps)); } // Sort by date, newest first usort($allItems, function($a, $b) { return $b['date'] - $a['date']; }); // Log the total number of stories $totalStories = count($allItems); logMessage("Total stories fetched: $totalStories"); // Apply inclusion filters to data $outputItems = applyInclusionFilters($allItems, $requestedFeeds); // Limit number of stories if needed $outputItems = array_slice($outputItems, 0, $numStories); // Return filtered cached content echo json_encode($outputItems); // ***** Utility functions ***** function fetchRSS($url) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (compatible; RSS Reader/1.0)'); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); $response = curl_exec($ch); if (curl_errno($ch)) { error_log("RSS Feed Error: " . curl_error($ch) . " - URL: " . $url); curl_close($ch); return false; } curl_close($ch); return $response; } function parseRSS($xml, $source) { global $maxSingleSource, $feeds; try { $feed = simplexml_load_string($xml); if (!$feed) { error_log("RSS Parse Error: Failed to parse XML feed from source: {$source}"); return []; } } catch (Exception $e) { error_log("RSS Parse Exception for source {$source}: " . $e->getMessage()); return []; } $items = []; // Handle different RSS feed structures $feedItems = null; if (isset($feed->channel) && isset($feed->channel->item)) { $feedItems = $feed->channel->item; // Standard RSS format } elseif (isset($feed->entry)) { $feedItems = $feed->entry; // Atom format } elseif (isset($feed->item)) { $feedItems = $feed->item; // Some non-standard RSS variants } if (!$feedItems) return []; // Get icon if present for this source $icon = isset($feeds[$source]['icon']) ? $feeds[$source]['icon'] : null; foreach ($feedItems as $item) { // Try to find the publication date in various formats $pubDate = null; $dateString = null; // Check for different date fields if (isset($item->pubDate)) { $dateString = (string)$item->pubDate; } elseif (isset($item->published)) { $dateString = (string)$item->published; } elseif (isset($item->updated)) { $dateString = (string)$item->updated; } elseif (isset($item->children('dc', true)->date)) { $dateString = (string)$item->children('dc', true)->date; } if ($dateString) { // Try to parse the date $pubDate = strtotime($dateString); // If parsing failed, try to reformat common date patterns if ($pubDate === false) { // Try ISO 8601 format (remove milliseconds if present) if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $dateString)) { $cleaned = preg_replace('/\.\d+/', '', $dateString); $pubDate = strtotime($cleaned); } // Try common RFC formats with missing timezone if ($pubDate === false && preg_match('/^\w+, \d+ \w+ \d+$/', $dateString)) { $pubDate = strtotime($dateString . " 00:00:00 +0000"); } // Last resort: use current time if ($pubDate === false) { error_log("Failed to parse date: {$dateString} from source: {$source}"); $pubDate = time(); } } } else { // If no date is found, use current time (FIX: bad idea) $pubDate = time(); } // Find the link (which could be in different formats) $link = ""; if (isset($item->link)) { if (is_object($item->link) && isset($item->link['href'])) { $link = (string)$item->link['href']; // Atom format } else { $link = (string)$item->link; // RSS format } } // Find the title $title = isset($item->title) ? (string)$item->title : "No Title"; $newsItem = [ 'title' => $title, 'link' => $link, 'date' => $pubDate, 'source' => $source ]; if ($icon) { $newsItem['icon'] = $icon; } $items[] = $newsItem; // Limit number from single source if (count($items) > $maxSingleSource) { logMessage("Limiting number of stories from source: {$source}"); break; } } logMessage("Fetched " . count($items) . " stories from source: {$source}"); return $items; } // Function to apply exclusion filters to items function applyExclusionFilters($items, $excludedFeeds) { if (empty($excludedFeeds)) { return $items; } logMessage("Filtering out excluded feeds: " . implode(', ', $excludedFeeds)); $filteredItems = array_filter($items, function($item) use ($excludedFeeds) { return !in_array($item['source'], $excludedFeeds); }); // Re-index array after filtering $filteredItems = array_values($filteredItems); logMessage("After filtering: " . count($filteredItems) . " items remain"); return $filteredItems; } // Function to apply inclusion filters to items function applyInclusionFilters($items, $includedFeeds) { if (empty($includedFeeds)) { // If no feeds are specified, include all feeds return $items; } logMessage("Filtering to only include feeds: " . implode(', ', $includedFeeds)); $filteredItems = array_filter($items, function($item) use ($includedFeeds) { return in_array($item['source'], $includedFeeds); }); // Re-index array after filtering $filteredItems = array_values($filteredItems); logMessage("After filtering: " . count($filteredItems) . " items remain"); return $filteredItems; } // Function to write timestamped log messages to the end of the log file function logMessage($message) { global $logFile; file_put_contents($logFile, date('[Y-m-d H:i:s] ') . $message . "\n", FILE_APPEND); } PK馬9/4 /4 PK- Z settings.js SV // Imports import { updateNews, setShareButtonsVisibility } from './news.js'; import { updateChartAxisColors } from './net.js'; import { autoDarkMode, updatePremiumWeatherDisplay } from './wx.js'; // Global variables let isLoggedIn = false; let currentUser = null; // Will be NULL if not logged in OR if using auto-generated ID let hashedUser = null; // The hashed version of the user ID let rssIsDirty = false; // Flag to indicate if RSS settings have changed let rssDrop = false; // Flag to indicate if an RSS feed has been dropped let unitIsDirty = false; // Flag to indicate if unit/time settings have changed let settings = {}; // Initialize settings object // Export settings object so it's accessible to other modules export { settings, currentUser, isLoggedIn, hashedUser }; // Default settings that will be used when no user is logged in const defaultSettings = { // General settings "dark-mode": false, "auto-dark-mode": true, "24-hour-time": false, "imperial-units": true, "map-choice": 'waze', "show-wind-radar": true, // News forwarding "news-forwarding": false, "news-forward-only": false, "forwarding-email": "", // News source settings "rss-wsj": true, "rss-nyt": true, "rss-wapo": true, "rss-latimes": false, "rss-bos": false, "rss-den": false, "rss-chi": false, "rss-bloomberg": false, "rss-ap": true, "rss-bbc": false, "rss-economist": false, "rss-lemonde": false, "rss-cnn": false, "rss-notateslaapp": true, "rss-teslarati": true, "rss-insideevs": true, "rss-thedrive": false, "rss-techcrunch": true, "rss-caranddriver": true, "rss-theverge": false, "rss-arstechnica": true, "rss-engadget": false, "rss-gizmodo": false, "rss-wired": false, "rss-spacenews": false, "rss-defensenews": false, "rss-aviationweek": false, }; // Settings section is being left export function leaveSettings() { if (rssIsDirty) { console.log('RSS settings are dirty, updating news feed.') // If RSS is dirty, update the news feed updateNews(rssDrop); rssIsDirty = false; // Reset the dirty flag rssDrop = false; // Reset the drop flag } if (unitIsDirty) { console.log('Unit/time settings are dirty, updating weather display.') updatePremiumWeatherDisplay(); unitIsDirty = false; // Reset the dirty flag } } // Turn on dark mode export function turnOnDarkMode() { console.log('turnOnDarkMode() called'); document.body.classList.add('dark-mode'); document.getElementById('darkModeToggle').checked = true; toggleSetting('dark-mode', true); updateDarkModeDependants(); } // Turn off dark mode export function turnOffDarkMode() { console.log('turnOffDarkMode() called'); document.body.classList.remove('dark-mode'); document.getElementById('darkModeToggle').checked = false; toggleSetting('dark-mode', false); updateDarkModeDependants(); } // Function to attempt login export async function attemptLogin() { const urlParams = new URLSearchParams(window.location.search); let userId = urlParams.get('user'); // deprecated, but keep for now // Check for user-set ID in cookies if not found in URL if (!userId) { userId = getCookie('userid'); } // Get final hashedUserID either from named user or auto-generated user if (userId) { // We have a named user if (await validateUserId(userId)) { console.log('Logged in named user ID: ', userId); await fetchSettings(); } } else { // Fall back to an auto-generated one const autoGeneratedId = getCookie('auto-userid'); if (autoGeneratedId && await validateAutoUserId(autoGeneratedId)) { console.log('Logged in auto-generated ID: ', autoGeneratedId); await fetchSettings(); } else { console.log('No user IDs found, creating new auto-generated user...'); initializeSettings(); const newAutoUser = await autoCreateUser(); // Create a new user and log them in if (await validateAutoUserId(newAutoUser)) { for (const [key, value] of Object.entries(settings)) { await toggleSetting(key, value); } } } } // Log final state of currentUser, hashedUser, and isLoggedIn console.log('currentUser:', currentUser); console.log('hashedUser:', hashedUser); console.log('isLoggedIn:', isLoggedIn); // Initialize map frame option updateMapFrame(); } // Function to toggle a setting (updates both local cache and server) export async function toggleSetting(key, value) { // Handle local settings settings[key] = value; // Update toggle state visually updateToggleVisualState(key, value); console.log(`Setting "${key}" updated to ${value} (local)`); // Update server if logged in if (isLoggedIn && hashedUser) { try { // Update the local settings cache with boolean value settings[key] = value; // Update the server with the boolean value using the RESTful API const response = await fetch(`settings.php/${encodeURIComponent(hashedUser)}/${encodeURIComponent(key)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: value }) }); if (response.ok) { console.log(`Setting "${key}" updated to ${value} (server)`); } else { console.log(`Failed to update setting "${key}" on server`); } } catch (error) { console.log('Error toggling setting:', error); } } // If the setting is RSS-related, set the dirty flag if (key.startsWith('rss-')) { const isDrop = !value; // If unchecked, it's a drop rssIsDirty = true; rssDrop = rssDrop || isDrop; // Set the drop flag if this is a drop console.log(`RSS setting "${key}" changed to ${value} (dirty: ${rssIsDirty}, drop: ${rssDrop})`); } // If the setting is unit/time-related, set the dirty flag if (key === 'imperial-units' || key === '24-hour-time') { unitIsDirty = true; console.log(`Unit/time setting "${key}" changed to ${value} (dirty: ${unitIsDirty})`); } // If the setting is dark mode related, update the dark mode if (key === 'auto-dark-mode') { if (value) { autoDarkMode(); } } // Handle map choice setting if (key === 'map-choice') { updateMapFrame(); } // If the setting is news forwarding, update the share buttons if (key === 'news-forwarding') { setShareButtonsVisibility(); } // Show/hide radar if setting changes if (key === 'show-wind-radar') { updateRadarVisibility(); } } // Function to initialize with defaults function initializeSettings() { settings = { ...defaultSettings }; initializeToggleStates(); updateRadarVisibility(); console.log('Settings initialized: ', settings); } // Update things that depend on dark mode function updateDarkModeDependants() { updateChartAxisColors(); } // Function to show/hide radar display based on setting function updateRadarVisibility() { const radar = document.getElementById('radar-container'); if (radar) { radar.style.display = (settings["show-wind-radar"] === false) ? 'none' : ''; } } // Function to hash a user ID using SHA-256 async function hashUserId(userId) { // Use the SubtleCrypto API to create a SHA-256 hash const encoder = new TextEncoder(); const data = encoder.encode(userId); const hashBuffer = await crypto.subtle.digest('SHA-256', data); // Convert the hash buffer to a hexadecimal string const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // Return only the first 16 characters (64 bits) of the hash return hashHex.substring(0, 16); } // Internal helper function async function validateHashedUserId(hashedId) { try { // Use HEAD request to check if the user exists const response = await fetch(`settings.php/${encodeURIComponent(hashedId)}`, { method: 'HEAD' }); if (response.status === 404) { // User doesn't exist, create default settings const created = await createNewUser(userId, hashedId); if (!created) { return false; } } else if (!response.ok) { return false; } // User exists, set environment variables isLoggedIn = true; hashedUser = hashedId; currentUser = null; console.log('User validated on server: ', hashedId); return true; } catch (error) { isLoggedIn = false; hashedUser = null; currentUser = null; console.error('Error validating user: ', error); return false; } } async function validateAutoUserId(autoUserId) { if (await validateHashedUserId(autoUserId)) { setCookie('auto-userid', autoUserId); updateLoginState(); return true; } else { return false; } } // Function to validate user ID, creating a new user if it doesn't exist async function validateUserId(userId) { // Check for minimum length (9 characters) if (userId.length < 9) { return false; } // Check for standard characters (letters, numbers, underscore, hyphen) const validFormat = /^[a-zA-Z0-9_-]+$/; if (!validFormat.test(userId)) { return false; } // Hash the user ID before sending to the server const hashedId = await hashUserId(userId); if (await validateHashedUserId(hashedId)) { currentUser = userId; setCookie('userid', userId); updateLoginState(); return true; } else { currentUser = null; return false; } } // Function to create a new named user, always called with initialized settings async function createNewUser(userId, hashedId = null) { try { // If hashedId wasn't provided, generate it if (!hashedId) { hashedId = await hashUserId(userId); } const response = await fetch(`settings.php/${encodeURIComponent(hashedId)}`, { method: 'POST' }); if (response.ok) { console.log('Created new user with default settings:', userId); for (const [key, value] of Object.entries(settings)) { await toggleSetting(key, value); } return true; } else { console.log('Failed to create new user:', userId); return false; } } catch (error) { console.error('Error creating new user:', error); return false; } } // Function to generate an auto-generated user from the server and return the hash async function autoCreateUser() { try { const response = await fetch('settings.php', { method: 'POST' }); if (response.ok) { let data = await response.json(); console.log('Auto-generated user ID:', data.userId); return data.userId; } else { console.log('Failed to fetch random user ID from server'); return null; } } catch (error) { console.error('Error fetching random user ID:', error); return null; } } // Update login/logout button visibility based on state function updateLoginState() { const loginButton = document.getElementById('login-button'); const logoutButton = document.getElementById('logout-button'); if (isLoggedIn) { loginButton.classList.add('hidden'); logoutButton.classList.remove('hidden'); if (currentUser) { logoutButton.textContent = `Logout ${currentUser}`; } else { logoutButton.textContent = 'Logout default user'; } } else { loginButton.classList.remove('hidden'); logoutButton.classList.add('hidden'); logoutButton.textContent = 'Logout'; } } // Pull all settings for current valided user from REST server // TODO: fetchSettings should return a boolean indicating success or failure async function fetchSettings() { if (!hashedUser) { console.log('No hashed user ID available, cannot fetch settings.'); return; } try { // Fetch settings using RESTful API console.log('Fetching settings for user: ', hashedUser); const response = await fetch(`settings.php/${encodeURIComponent(hashedUser)}`, { method: 'GET' }); if (response.ok) { // Load settings settings = await response.json(); console.log('Settings loaded: ', settings); // Activate the settings section button document.getElementById('settings-section').classList.remove('hidden'); // Initialize toggle states based on settings initializeToggleStates(); updateRadarVisibility(); // Handle dark mode if (settings['dark-mode']) { turnOnDarkMode(); } else { turnOffDarkMode(); } } else { console.error('Error fetching settings: ', response.statusText); } } catch (error) { console.error('Error fetching settings: ', error); } } // Update visual state of a toggle or text input function updateToggleVisualState(key, value) { const settingItems = document.querySelectorAll(`.settings-toggle-item[data-setting="${key}"]`); // Special compatibility cases if (key === 'imperial-units') { let unitsValue; if (value === true) { value = 'english'; } else { value = 'metric'; } } console.log(`Updating visual state for "${key}" to ${value}`); if (settingItems && settingItems.length > 0) { settingItems.forEach(item => { // Handle checkbox toggle const toggle = item.querySelector('input[type="checkbox"]'); if (toggle) { toggle.checked = value === true; } // Handle option-based toggles if (item.classList.contains('option-switch-container')) { const buttons = item.querySelectorAll('.option-button'); buttons.forEach(btn => { btn.classList.toggle('active', btn.dataset.value === value); }); } // Handle text input if (item.classList.contains('settings-text-item')) { const textInput = item.querySelector('input[type="text"]'); if (textInput) { textInput.value = value || ''; } } }); } // Disable/enable forwarding-email input based on news-forwarding if (key === 'news-forwarding') { setControlEnable('forwarding-email', value); setControlEnable('news-forward-only', value); } } function setControlEnable(key, enabled = true) { const settingItems = document.querySelectorAll(`div[data-setting="${key}"]`); if (settingItems && settingItems.length > 0) { settingItems.forEach(item => { // Make div partly transparent item.style.opacity = enabled ? '1' : '0.35'; // Handle checkbox toggle const toggle = item.querySelector('input[type="checkbox"]'); if (toggle) { toggle.disabled = !enabled; } // Handle option-based toggles if (item.classList.contains('option-switch-container')) { const buttons = item.querySelectorAll('.option-button'); buttons.forEach(btn => { btn.disabled = !enabled; }); } // Handle text input if (item.classList.contains('settings-text-item')) { const textInput = item.querySelector('input[type="text"]'); if (textInput) { textInput.disabled = !enabled; } } }); } } // Initialize all toggle and text states based on 'settings' dictionary function initializeToggleStates() { // Iterate through all keys in the settings object for (const key in settings) { if (settings.hasOwnProperty(key)) { const value = settings[key]; updateToggleVisualState(key, value); } } updateRadarVisibility(); } // Helper function to get current domain for cookie namespacing function getCurrentDomain() { // Get hostname (e.g., example.com or beta.example.com) const hostname = window.location.hostname; // Convert to a safe string for use in cookie names return hostname.replace(/[^a-zA-Z0-9]/g, '_'); } function setCookie(name, value, days = 36500) { // Default to ~100 years (forever) // Namespace the cookie name with the current domain const domainSpecificName = `${getCurrentDomain()}_${name}`; const d = new Date(); d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000)); const expires = "expires=" + d.toUTCString(); document.cookie = domainSpecificName + "=" + value + ";" + expires + ";path=/"; console.log(`Cookie set: ${domainSpecificName}=${value}, expires: ${d.toUTCString()}`); } function getCookie(name) { // Namespace the cookie name with the current domain const domainSpecificName = `${getCurrentDomain()}_${name}`; const cookieName = domainSpecificName + "="; const decodedCookie = decodeURIComponent(document.cookie); const cookieArray = decodedCookie.split(';'); // console.log(`All cookies: ${document.cookie}`); for (let i = 0; i < cookieArray.length; i++) { let cookie = cookieArray[i]; while (cookie.charAt(0) === ' ') { cookie = cookie.substring(1); } if (cookie.indexOf(cookieName) === 0) { const value = cookie.substring(cookieName.length, cookie.length); console.log(`Cookie found: ${domainSpecificName}=${value}`); return value; } } console.log(`Cookie not found: ${domainSpecificName}`); return ""; } function deleteCookie(name) { // Namespace the cookie name with the current domain const domainSpecificName = `${getCurrentDomain()}_${name}`; document.cookie = domainSpecificName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; console.log(`Cookie deleted: ${domainSpecificName}`); } // Function to show the login modal window.showLoginModal = function () { const modal = document.getElementById('login-modal'); modal.style.display = 'flex'; document.getElementById('user-id').focus(); document.getElementById('login-error').textContent = ''; // Clear previous errors } // Function to hide the login modal window.closeLoginModal = function () { document.getElementById('login-modal').style.display = 'none'; } // Function to handle logout window.handleLogout = function () { isLoggedIn = false; currentUser = null; hashedUser = null; // Update UI updateLoginState(); // Hide settings section document.getElementById('settings-section').classList.add('hidden'); // If currently in settings section, redirect to default section const settingsSection = document.getElementById('settings'); if (settingsSection.style.display === 'block') { showSection('news'); } // Ensure we won't auto login to a named user deleteCookie('userid'); } // Function to handle login from dialog window.handleLogin = async function () { const userId = document.getElementById('user-id').value.trim(); closeLoginModal(); console.log('Attempting login with user ID: ', userId); try { if (await validateUserId(userId)) { console.log('User ID validated successfully.'); await fetchSettings(); console.log('Login successful, updating news feed...'); updateNews(true); // Update news feed after login } } catch (error) { console.error('Error fetching settings: ', error); } } // Manually swap dark/light mode window.toggleMode = function () { console.log('Toggling dark mode manually.'); toggleSetting('auto-dark-mode', false); document.body.classList.toggle('dark-mode'); const darkMode = document.body.classList.contains('dark-mode'); document.getElementById('darkModeToggle').checked = darkMode; toggleSetting('dark-mode', darkMode); updateDarkModeDependants(); } // Function called by the toggle UI elements window.toggleSettingFrom = function(element) { console.log('Toggle setting from UI element.'); // const settingItem = element.closest('.settings-toggle-item'); // Find closest element with a data-setting attribute const settingItem = element.closest('[data-setting]'); if (settingItem && settingItem.dataset.setting) { const key = settingItem.dataset.setting; const value = element.checked; toggleSetting(key, value); } } // Function for toggling option-based settings (like map-choice) window.toggleOptionSetting = function(button) { const settingItem = button.closest('.option-switch-container'); if (!settingItem || !settingItem.dataset.setting) return; const key = settingItem.dataset.setting; let value = button.dataset.value; // Handle special cases for compatibility if (key === 'imperial-units') { // Convert value to boolean value = (value === 'english'); } // Store the setting toggleSetting(key, value); console.log(`Option setting "${key}" changed to "${value}"`); } // Function called by the text input UI elements for text-based settings window.updateSettingFrom = function(element) { const settingItem = element.closest('.settings-text-item'); if (settingItem && settingItem.dataset.setting) { const key = settingItem.dataset.setting; const value = element.value.trim(); toggleSetting(key, value); } } PKc:9X 9X PK- G.Z settings.php SV date('Y-m-d H:i:s'), "version" => "1" ]; // Load .env variables from a JSON file $envFilePath = __DIR__ . '/.env'; if (file_exists($envFilePath)) { $envContent = file_get_contents($envFilePath); $envVariables = json_decode($envContent, true); if (json_last_error() === JSON_ERROR_NONE) { foreach ($envVariables as $key => $value) { $_ENV[$key] = $value; } } else { error_log("Failed to parse .env file: " . json_last_error_msg()); } } else { error_log(".env file not found at $envFilePath"); } // Function to get client IP address accounting for proxies function getClientIP() { if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { // If the site is behind a proxy, get the real client IP $ip = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]; } elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) { $ip = $_SERVER['HTTP_CLIENT_IP']; } else { $ip = $_SERVER['REMOTE_ADDR']; } return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : 'unknown'; } // SQL database configuration $dbName = $_ENV['SQL_DB_NAME'] ?? 'teslacloud'; $dbHost = $_ENV['SQL_HOST'] ?? null; $dbUser = $_ENV['SQL_USER'] ?? null; $dbPass = $_ENV['SQL_PASS'] ?? null; $dbPort = $_ENV['SQL_PORT'] ?? '3306'; // Establish database connection if (!$dbHost || !$dbName || !$dbUser) { logMessage("Missing required database configuration", "ERROR"); http_response_code(500); echo json_encode(['error' => 'Database configuration missing']); exit; } // Connect to database try { $dsn = "mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; $dbConnection = new PDO($dsn, $dbUser, $dbPass, $options); // Check if the required table exists, create it if not $tableCheck = $dbConnection->query("SHOW TABLES LIKE 'user_settings'"); if ($tableCheck->rowCount() == 0) { $sql = "CREATE TABLE user_settings ( user_id VARCHAR(255) NOT NULL, setting_key VARCHAR({$maxKeyLength}) NOT NULL, setting_value TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id, setting_key) )"; $dbConnection->exec($sql); } // Check if user_ids table exists, create it if not $userIdsTableCheck = $dbConnection->query("SHOW TABLES LIKE 'user_ids'"); if ($userIdsTableCheck->rowCount() == 0) { $sql = "CREATE TABLE user_ids ( user_id VARCHAR(255) NOT NULL, initial_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP, login_count INT DEFAULT 0, PRIMARY KEY (user_id) )"; $dbConnection->exec($sql); logMessage("Created user_ids table"); } // Check if login_hist table exists, create it if not $loginHistTableCheck = $dbConnection->query("SHOW TABLES LIKE 'login_hist'"); if ($loginHistTableCheck->rowCount() == 0) { $sql = "CREATE TABLE login_hist ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, ip_address VARCHAR(45) NOT NULL )"; $dbConnection->exec($sql); logMessage("Created login_hist table"); } } catch (PDOException $e) { $errorMessage = "Database connection failed: " . $e->getMessage(); logMessage($errorMessage, "ERROR"); http_response_code(500); echo json_encode(['error' => 'Database connection failed']); exit; } // Parse the request URI to extract user and key $requestUri = $_SERVER['REQUEST_URI']; $uriParts = explode('/', trim(parse_url($requestUri, PHP_URL_PATH), '/')); // Determine which parts of the URL contain our parameters $userId = null; $key = null; // Check if we have enough parts to contain a user ID if (count($uriParts) > 1) { $scriptName = basename(__FILE__); // Should be settings.php $scriptPos = array_search($scriptName, $uriParts); if ($scriptPos !== false && isset($uriParts[$scriptPos + 1])) { $userId = $uriParts[$scriptPos + 1]; // Check if we also have a key // TODO: Just return all the rest of the parts concatenated if (isset($uriParts[$scriptPos + 2])) { $key = $uriParts[$scriptPos + 2]; } } } // Handle the request based on method $method = $_SERVER['REQUEST_METHOD']; switch ($method) { case 'POST': // POST request - create a new user settings resource // Check if userId is valid if ($userId && !validateUserId($userId)) { logMessage("POST: Invalid user ID: $userId", "ERROR"); http_response_code(400); exit; } // Check if user settings already exist if ($userId && userSettingsExist($userId)) { logMessage("POST: User settings already exist for $userId", "WARNING"); http_response_code(409); // Conflict exit; } // Generate userId if none is provided if (!$userId) { logMessage("POST: Creating new user with random ID", "INFO"); $userId = bin2hex(string: random_bytes(length: 4)); // Generate a random user ID $automated = true; } else { $automated = false; } logMessage("POST: Creating user settings for $userId...", "INFO"); if (initializeUserIdEntry(userId: $userId, auto_created: $automated)) { saveUserSettings(userId: $userId, settings: $defaultSettings); // Default settings logMessage("POST: User settings created successfully for $userId", "INFO"); http_response_code(201); // Created echo json_encode([ 'success' => true, 'userId' => $userId, 'auto_generated' => $automated, 'message' => 'User settings created with default values.', 'settings' => $defaultSettings ]); } else { logMessage("POST: Failed to create user settings for $userId", "ERROR"); http_response_code(500); } break; case 'HEAD': // HEAD request - check if user settings exist without returning content if (!$userId || !validateUserId($userId)) { logMessage("HEAD: Invalid or missing user ID: $userId", "ERROR"); http_response_code(400); exit; } if (!userSettingsExist($userId)) { logMessage("HEAD: User settings not found for $userId", "WARNING"); http_response_code(404); exit; } // Update user_ids table - update last_login timestamp and increment login_count initializeUserIdEntry($userId); // Record login in login_hist table recordLogin($userId); // Resource exists, return 200 OK (with no body) http_response_code(200); exit; case 'GET': // GET request - retrieve settings for a user if (!$userId || !validateUserId($userId)) { logMessage("GET: Invalid or missing user ID: $userId", "ERROR"); http_response_code(400); echo json_encode(['error' => 'Invalid or missing user ID in URL path']); exit; } // Check if the user settings exist if (!userSettingsExist($userId)) { logMessage("GET: User settings not found for $userId", "WARNING"); http_response_code(404); exit; } if ($key) { // Check if there's an exact match for the key $exactValue = getSingleSetting($userId, $key); if ($exactValue !== null) { // Key exists, return just this value echo json_encode([$key => $exactValue]); } else { // No exact match, try to get settings with this prefix $settingsWithPrefix = getSettingsWithPrefix($userId, $key); if (!empty($settingsWithPrefix)) { echo json_encode($settingsWithPrefix); } else { logMessage("GET: No settings found with key or prefix '$key' for user $userId", "WARNING"); http_response_code(404); } } } else { // No key provided, return all settings $settings = loadUserSettings($userId); echo json_encode($settings); } break; case 'PUT': // PUT request - update or create a setting if (!$userId || !validateUserId($userId)) { logMessage("Invalid or missing user ID: $userId", "ERROR"); http_response_code(400); exit; } if (!$key) { logMessage("Missing key in URL path", "ERROR"); http_response_code(400); exit; } // Validate key length if (strlen($key) > $maxKeyLength) { logMessage("Key too long: $key", "ERROR"); http_response_code(400); exit; } // Parse the input $requestData = json_decode(file_get_contents('php://input'), true); if (!isset($requestData['value'])) { logMessage("Missing value parameter", "ERROR"); http_response_code(400); exit; } $value = $requestData['value']; // Validate value length if (strlen(json_encode($value)) > $maxValueLength) { logMessage("Value too long", "ERROR"); http_response_code(400); echo json_encode(['error' => 'Value too long']); exit; } // Convert value to boolean if it's a boolean string or already boolean if (is_string($value)) { if ($value === 'true') { $value = true; } elseif ($value === 'false') { $value = false; } // Keep other string values as is - this handles non-boolean settings } // Check if this is a new resource creation $isCreatingResource = !userSettingsExist($userId); // Update the single setting instead of all settings if (updateSingleSetting($userId, $key, $value)) { // Return 201 Created if this was a new resource, otherwise 200 OK if ($isCreatingResource) { http_response_code(201); echo json_encode(['success' => true, 'key' => $key, 'created' => true]); } else { echo json_encode(['success' => true, 'key' => $key]); } } else { logMessage("Failed to save setting $key for user $userId", "ERROR"); http_response_code(500); } break; default: logMessage("Invalid method: $method", "ERROR"); // Method not allowed http_response_code(405); break; } // ***** Utility Functions ***** // Helper function to initialize or update a user entry in the user_ids table function initializeUserIdEntry($userId, $auto_created = false): bool { global $dbConnection; logMessage("Initializing/updating user_ids entry for user $userId"); try { $currentTime = date('Y-m-d H:i:s'); // First check if the user already exists in the user_ids table $checkStmt = $dbConnection->prepare("SELECT 1 FROM user_ids WHERE user_id = ? LIMIT 1"); $checkStmt->execute([$userId]); $userExists = $checkStmt->rowCount() > 0; if ($userExists) { // Update existing user's last_login and increment login_count $updateStmt = $dbConnection->prepare(" UPDATE user_ids SET last_login = ?, login_count = login_count + 1, last_ip = ? WHERE user_id = ? "); $updateStmt->execute([$currentTime, getClientIP(), $userId]); logMessage("Updated login statistics for user $userId"); } else { // Create new user entry with initial values $auto_created_bit = $auto_created ? 1 : 0; $insertStmt = $dbConnection->prepare(" INSERT INTO user_ids (user_id, initial_login, last_login, last_ip, login_count, auto_created) VALUES (?, ?, ?, ?, 1, ?) "); $insertStmt->execute([$userId, $currentTime, $currentTime, getClientIP(), $auto_created_bit]); logMessage("Added user $userId to user_ids table with initial login at $currentTime"); } return true; } catch (PDOException $e) { logMessage("Failed to initialize user_ids entry: " . $e->getMessage(), "WARNING"); // Non-fatal error return false; } } // Helper function to update a single setting function updateSingleSetting($userId, $key, $value) { global $dbConnection; logMessage("Updating single setting for user $userId, key: $key"); try { $jsonValue = json_encode($value); // Check if this key already exists for the user $checkStmt = $dbConnection->prepare("SELECT 1 FROM user_settings WHERE user_id = ? AND setting_key = ? LIMIT 1"); $checkStmt->execute([$userId, $key]); $exists = $checkStmt->rowCount() > 0; if ($exists) { // Update existing key logMessage("Key $key exists, updating it"); $updateStmt = $dbConnection->prepare("UPDATE user_settings SET setting_value = ? WHERE user_id = ? AND setting_key = ?"); $updateStmt->execute([$jsonValue, $userId, $key]); } else { // Insert new key logMessage("Key $key does not exist, inserting it"); $insertStmt = $dbConnection->prepare("INSERT INTO user_settings (user_id, setting_key, setting_value) VALUES (?, ?, ?)"); $insertStmt->execute([$userId, $key, $jsonValue]); } logMessage("Successfully saved setting $key for user $userId"); return true; } catch (PDOException $e) { $errorMsg = "Database error updating setting: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); return false; } } // Helper function to get a single setting value function getSingleSetting($userId, $key) { global $dbConnection; logMessage("Getting single setting for user $userId, key: $key"); try { // Get the specific key value $stmt = $dbConnection->prepare("SELECT setting_value FROM user_settings WHERE user_id = ? AND setting_key = ?"); $stmt->execute([$userId, $key]); if ($stmt->rowCount() > 0) { $row = $stmt->fetch(); $value = json_decode($row['setting_value'], true); logMessage("Found setting $key for user $userId"); return $value !== null ? $value : $row['setting_value']; } else { logMessage("Setting $key not found for user $userId", "WARNING"); return null; } } catch (PDOException $e) { $errorMsg = "Database error getting setting: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); throw $e; } } // Helper function to get settings with a prefix function getSettingsWithPrefix($userId, $keyPrefix) { global $dbConnection; logMessage("Getting settings with prefix '$keyPrefix' for user $userId"); try { $stmt = $dbConnection->prepare("SELECT setting_key, setting_value FROM user_settings WHERE user_id = ? AND setting_key LIKE ?"); $stmt->execute([$userId, $keyPrefix . '%']); $settings = []; while ($row = $stmt->fetch()) { $value = json_decode($row['setting_value'], true); $settings[$row['setting_key']] = $value !== null ? $value : $row['setting_value']; } logMessage("Found " . count($settings) . " setting(s) with prefix '$keyPrefix' for user $userId"); return $settings; } catch (PDOException $e) { $errorMsg = "Database error getting settings with prefix: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); throw $e; } } // Helper function to validate user ID function validateUserId($userId) { $isValid = (strlen($userId) >= 8) && preg_match('/^[a-zA-Z0-9_-]+$/', $userId); logMessage("Validating user ID: $userId - " . ($isValid ? "Valid" : "Invalid")); return $isValid; } // Helper function to check if user settings exist function userSettingsExist($userId) { global $dbConnection; try { logMessage("Checking if user $userId exists in database"); $stmt = $dbConnection->prepare("SELECT 1 FROM user_settings WHERE user_id = ? LIMIT 1"); $stmt->execute([$userId]); $exists = $stmt->rowCount() > 0; logMessage("Database check result: " . ($exists ? "User exists" : "User does not exist")); return $exists; } catch (PDOException $e) { $errorMsg = "Database error checking if user settings exist: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); throw $e; // Rethrow the exception after logging } } // Helper function to load user settings function loadUserSettings($userId) { global $dbConnection, $defaultSettings; logMessage("Loading settings for user $userId"); try { logMessage("Loading settings from database for $userId"); $settings = []; $stmt = $dbConnection->prepare("SELECT setting_key, setting_value FROM user_settings WHERE user_id = ?"); $stmt->execute([$userId]); $rowCount = $stmt->rowCount(); logMessage("Found $rowCount setting(s) in database for user $userId"); if ($rowCount > 0) { while ($row = $stmt->fetch()) { // Parse stored JSON value or use as is if parsing fails $value = json_decode($row['setting_value'], true); $settings[$row['setting_key']] = ($value !== null) ? $value : $row['setting_value']; logMessage("Loaded setting {$row['setting_key']} from database with value type: " . gettype($value)); } return $settings; } // If no settings in DB but this was called, create default settings logMessage("No settings found in database, saving defaults", "WARNING"); saveUserSettings($userId, $defaultSettings); return $defaultSettings; } catch (PDOException $e) { $errorMsg = "Database error loading user settings: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); throw $e; // Rethrow the exception after logging } } // Helper function to save user settings function saveUserSettings($userId, $settings) { global $dbConnection; logMessage("Saving settings for user $userId - " . count($settings) . " setting(s)"); try { logMessage("Saving settings to database"); $dbConnection->beginTransaction(); // Delete existing settings for this user $deleteStmt = $dbConnection->prepare("DELETE FROM user_settings WHERE user_id = ?"); $deleteStmt->execute([$userId]); $deletedCount = $deleteStmt->rowCount(); logMessage("Deleted $deletedCount existing setting(s) for user $userId"); // Insert new settings $insertStmt = $dbConnection->prepare("INSERT INTO user_settings (user_id, setting_key, setting_value) VALUES (?, ?, ?)"); $insertCount = 0; foreach ($settings as $key => $value) { $jsonValue = json_encode($value); $insertStmt->execute([$userId, $key, $jsonValue]); $insertCount++; logMessage("Inserted setting: $key with value type: " . gettype($value)); } $dbConnection->commit(); logMessage("Database transaction committed successfully - inserted $insertCount setting(s)"); return true; } catch (PDOException $e) { $dbConnection->rollBack(); $errorMsg = "Database error saving user settings: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); return false; } } // Helper function to record login attempts function recordLogin($userId) { global $dbConnection; logMessage("Recording login for user $userId"); try { $stmt = $dbConnection->prepare("INSERT INTO login_hist (user_id, login_time, ip_address) VALUES (?, ?, ?)"); $stmt->execute([$userId, date('Y-m-d H:i:s'), getClientIP()]); logMessage("Recorded login for user $userId"); } catch (PDOException $e) { logMessage("Failed to record login for user $userId: " . $e->getMessage(), "WARNING"); // Non-fatal error } } // Simple logging function function logMessage($message, $level = 'INFO') { global $logFile; $timestamp = date('Y-m-d H:i:s'); $formattedMessage = "[$timestamp] [$level] $message" . PHP_EOL; file_put_contents($logFile, $formattedMessage, FILE_APPEND); } PK#V #V PK- m.Z share.php SV $value) { $_ENV[$key] = $value; } } else { error_log("Failed to parse .env file: " . json_last_error_msg()); } } else { error_log(".env file not found at $envFilePath"); } // Check if API key is set if (!isset($_ENV['SENDGRID_KEY'])) { http_response_code(500); echo json_encode(['error' => 'API key not found in .env file']); exit; } // Only allow POST requests if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo 'Method Not Allowed'; exit; } // Parse JSON payload $input = json_decode(file_get_contents('php://input'), true); if (!$input || !isset($input['to']) || !isset($input['html'])) { http_response_code(400); echo 'Invalid payload'; exit; } // TODO: Get e-mail address from settings database by passing in hashed user ID $to = $input['to']; $subject = $input['subject'] ?? 'Article forwarded from teslas.cloud'; $htmlContent = $input['html']; $email = new \SendGrid\Mail\Mail(); $email->setFrom("feedback@birgefuller.com", "Birge & Fuller, LLC"); $email->setSubject($subject); $email->addTo($to); $email->addContent("text/plain", strip_tags($htmlContent)); $email->addContent("text/html", $htmlContent); $sendgrid = new \SendGrid($_ENV['SENDGRID_KEY']); try { $response = $sendgrid->send($email); print $response->statusCode() . "\n"; print_r($response->headers()); print $response->body() . "\n"; } catch (Exception $e) { echo 'Caught exception: '. $e->getMessage() ."\n"; } PK6H H PK- m.Z share.svg SV PK0 PK- $Z styles.css SV /* Variables */ :root { --bg-color: #efefef; --text-color: #777777; --active-section-bg: white; --separator-color: #cccccc; --button-bg: #dddddd; --button-text: #333333; --button-highlight: #f0f0f0; --info-bg: #d0d0d0; --tesla-blue: #0077ff; --tesla-red: #ff0000; --button-radius: 13px; --weather-switch-slider: #ffffff; --weather-warning-color: #ff7700; --status-poor: #ff9100; /* Yellow for poor connection */ --status-good: #00d000; /* Green for good connection */ --status-unavailable: #888888; /* Gray for unavailable/bad connection */ --status-rain: #00a1ff; /* Blue for rain indicator */ --heading-font-size: 17pt; --heading-font-weight: 700; } body.dark-mode { --bg-color: #1d1d1d; --text-color: #777777; --active-section-bg: #333333; --separator-color: #444444; --button-bg: #333333; --button-text: #d6d6d6; --button-highlight: #bdbdbd; --info-bg: #302f39; --weather-switch-slider: #222222; --weather-warning-color: #ff9900; --status-poor: #ffcd27; /* Yellow for poor connection */ --status-good: #5eff19; /* Green for good connection */ --status-unavailable: #888888; /* Gray for unavailable/bad connection */ --status-rain: #4fb8ff; /* Blue for rain indicator */ } /* Basic HTML Elements */ * { font-family: "Inter"; font-optical-sizing: auto; font-variant-ligatures: all; } body { background-color: var(--bg-color); color: var(--text-color); font-size: 16pt; padding: 0; text-align: left; font-weight: 500; } a { color: var(--tesla-blue); text-decoration: none; font-weight: 575; } h1 { color: var(--button-text); font-size: 19pt; margin-bottom: 15px; text-align: left; margin-top: 0px; font-weight: 250; } h2 { color: var(--button-text); font-size: var(--heading-font-size); margin-bottom: 9px; margin-top: 28px; font-weight: var(--heading-font-weight); } h3 { color: var(--button-text); font-size: calc(var(--heading-font-size) - 1pt); font-weight: calc(var(--heading-font-weight) - 100); margin-bottom: 7px; margin-top: 16px; } p { margin-top: 12px; margin-bottom: 12px; text-align: justify; max-width: 980px; } ul { padding-left: 25px; margin-top: 0px; margin-bottom: 0px; text-align: justify; max-width: 940px; } li { margin: 11px 0; padding-left: 0px; margin-left: 0px; } hr { border: 0; border-top: 1px solid var(--separator-color); margin-top: 28px; margin-bottom: 28px; } /* About */ .announcement p { background-color: #03a8f422; color: var(--button-text); border-radius: var(--button-radius); padding: 18px; font-style: none; font-weight: 700; text-align: center; /* text-transform: uppercase; */ max-width: 840px; place-self: left; } /* Layout */ .frame-container { display: flex; height: 100vh; width: 100%; position: absolute; top: 0; left: 0; overflow: hidden; /* Prevent scrolling on the frame-container */ } .left-frame { width: 300px; flex-shrink: 0; /* height: 100%; */ overflow-y: auto; padding: 10px 10px 5px 15px; box-sizing: border-box; /* Include padding in height calculations */ scrollbar-gutter: stable; scrollbar-width: thin; } .right-frame { flex-grow: 1; height: 100%; /* Ensure it spans the full height of the container */ overflow-y: auto; /* Enable scrolling for the right frame */ padding: 20px 45px 20px 15px; box-sizing: border-box; /* Include padding in the height calculation */ /* scrollbar-gutter: stable; */ scrollbar-width: thin; /* Use a thin scrollbar */ position: relative; /* Add positioning context for the absolute positioned external-site */ } .section { display: none; } .hidden { display: none; } /* External site container */ #external-site { position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; display: none; overflow: hidden; padding: 0; margin: 0; } #external-site iframe { width: 100%; height: 100%; border: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0; padding: 0; margin: 0; } /* Navigation */ .section-buttons { position: relative; display: flex; flex-direction: column; margin: 0; padding: 0; width: 250px; /* min-width: 250px; */ max-height: 100%; /* Ensure it doesn't exceed the container height */ /* overflow-y: auto; */ box-sizing: border-box; /* Include padding in height calculations */ } .section-button { color: var(--text-color); border-radius: var(--button-radius); background-color: transparent; font-size: 19pt; font-weight: 675; letter-spacing: 0.02em; padding: 16px 20px; border: none; cursor: pointer; text-align: left; margin-bottom: 5px; display: flex; align-items: center; transition: background-color 0.3s, color 0.3s; } .logout-button { color: var(--button-text); background-color: var(--button-bg); border-radius: var(--button-radius); font-size: 16pt; font-weight: 600; padding: 16px 20px; border: none; cursor: pointer; text-align: center; margin-top: 15px; width: auto; transition: background-color 0.3s; } .logout-button:hover { background-color: var(--active-section-bg); } .button-icon { width: 20px; height: 20px; margin-right: 12px; stroke: currentColor; flex-shrink: 0; } .section-button.active { background-color: var(--active-section-bg); color: var(--button-text); } .section-button:hover { background-color: var(--active-section-bg); } /* Notification dot for news section */ .section-button.has-notification { position: relative; } .section-button.has-notification::after { content: ""; position: absolute; top: 50%; /* Center vertically */ left: 5px; /* Moved further left from 10px */ transform: translateY(-50%); /* Perfect vertical centering */ width: 10px; height: 10px; background-color: var(--tesla-blue); border-radius: 50%; animation: pulse 2s 1; /* Changed from 3 pulses to 1 */ animation-fill-mode: forwards; /* Keep final state */ } @keyframes pulse { 0% { transform: translateY(-50%) scale(0.6); opacity: 0.6; } 50% { transform: translateY(-50%) scale(1.5); /* Larger pulse */ opacity: 1; } 100% { transform: translateY(-50%) scale(1); opacity: 1; } } /* Link Button Lists */ .button-list { list-style: none; padding: 0; margin-top: 0; display: grid; grid-template-columns: repeat(auto-fill, 280px); gap: 16px; justify-content: start; max-width: calc(280px * 5 + 16px * 4); /* Limit to 5 columns */ } .button-list li { margin: 0; } .button-list a { color: var(--button-text); background-color: var(--button-bg); border-radius: var(--button-radius); display: flex; padding-top: 24px; padding-bottom: 24px; padding-left: 9px; padding-right: 9px; transition: background-color 0.3s; width: auto; font-weight: 600; height: 40px; align-items: center; justify-content: center; gap: 10px; text-align: center; } .button-list a img { height: 36px; width: auto; border-radius: 7px; } /* Enhanced Image Visibility Classes */ .img-adaptive, .img-adaptive-light-invert, .img-adaptive-dark-invert { /* Base styling shared by all image adaptive classes */ filter: grayscale(100%) contrast(1.5); transition: filter 0.3s; } /* Invert colors only in light mode */ body:not(.dark-mode) .img-adaptive, body:not(.dark-mode) .img-adaptive-light-invert { filter: invert(100%); } /* Invert colors only in dark mode */ body.dark-mode .img-adaptive-dark-invert { filter: invert(100%); } /* Make white areas in images transparent */ .img-white-transparent { /* Remove the inverting filters */ mix-blend-mode: multiply; /* Keep this to make white transparent */ transition: filter 0.3s; } /* Add specific dark mode handling */ body.dark-mode .img-white-transparent { mix-blend-mode: screen; /* In dark mode, use screen blend mode to make white transparent */ filter: invert(100%) brightness(125%); /* Invert colors and slightly increase brightness for better visibility */ } /* Indicator container and dark-mode toggle */ .control-container { position: fixed; top: -5px; /* Changed from 10px to -5px to move above screen edge */ right: 20px; display: flex; align-items: center; background-color: var(--bg-color); /* Semi-transparent light mode background */ border-radius: 5px; padding: 8px 12px; z-index: 100; /* Ensure it stays above other content */ opacity: 0.75; } .toggle-label { color: var(--text-color); font-size: 15pt; font-weight: 600; margin-right: 10px; } .toggle-switch { position: relative; display: inline-block; width: 48px; /* Reduced by 20% from 60px */ height: 27px; /* Reduced by 20% from 34px */ } .toggle-switch input { opacity: 0; width: 0; height: 0; } .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #bbb; transition: .4s; border-radius: 34px; } .toggle-slider:before { position: absolute; content: ""; height: 21px; /* Reduced by 20% from 26px */ width: 21px; /* Reduced by 20% from 26px */ left: 3px; /* Adjusted from 4px */ bottom: 3px; /* Adjusted from 4px */ background-color: white; transition: .4s; border-radius: 50%; } input:checked + .toggle-slider { background-color: #2196F3; } input:focus + .toggle-slider { box-shadow: 0 0 1px #2196F3; } input:checked + .toggle-slider:before { transform: translateX(21px); /* Reduced by 20% from 26px */ } /* Data Display */ .data-info, .data-info-column { row-gap: 18px; /* max-width: 1080px; */ width: auto; /* Ensure it spans full width */ } .data-info { display: grid; grid-template-columns: repeat(auto-fill, 240px); column-gap: 28px; } .data-info-column { margin-top: 12px; display: flex; flex-direction: column; } .data-info-column .data-item { margin-right: 0; /* Ensure no extra margin */ width: auto; /* Ensure items span full width */ } .data-item { color: var(--button-text); font-weight: 600; font-size: var(--heading-font-size); display: block; margin-right: 0; padding-top: 0px; } .data-item:last-child { margin-right: 0; margin-bottom: 0; } .data-item h2 { color: var(--text-color); margin-top: 0px; margin-bottom: 4px; } /* Stats */ .nav-container { display: flex; gap: 50px; margin: 30px 0px -25px 0px; align-items: flex-start; } .nav-stats { display: grid; grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, auto); gap: 10px 0 0 0; width: 400px; } .stat-box { display: flex; flex-direction: column; align-items: left; min-width: 200px; height: 125px; /* fixed height added */ } .value-container { display: flex; align-items: center; /* Vertically align items in container */ } .stat-value { color: var(--button-text); font-size: 48pt; font-weight: 600; display: inline; font-variant-numeric: tabular-nums; } .stat-unit { color: var(--text-color); font-size: 24pt; font-weight: 650; font-family: sans-serif; display: inline; margin-left: 5px; /* Add some spacing between value and unit */ } .stat-label { color: var(--text-color); font-size: 13pt; font-weight: 650; margin-top: 0px; display: block; /* Ensure it's on its own line */ clear: both; /* Clear the float to ensure it appears below */ } /* Dashboard Display */ .radar-container { position: relative; padding-top: 30px; /* Added padding to push radar down */ margin-right: 30px; } .radar-title { position: absolute; top: 0px; left: 24px; text-align: center; margin: 0; } #radarDisplay { border-radius: 50%; background-color: rgba(0, 0, 0, 0.1); } .dark-mode #radarDisplay { background-color: rgba(255, 255, 255, 0.1); } /* Waze frame */ .teslawaze-container { height: calc(100vh - 370px); min-height: 280px; margin-bottom: 0; /* Ensure no bottom margin */ position: relative; /* Add positioning context */ } #teslawaze { border-radius: var(--button-radius); width: 100%; height: 100%; border: none; } /* News Elements */ .news-headlines { margin: 24px 0; } .news-item { background-color: var(--button-bg); border-radius: var(--button-radius); margin-bottom: 15px; padding: 9px 9px 9px 61px; max-width: 1200px; cursor: pointer; position: relative; /* Added for absolute positioning of the favicon */ } .news-new { position: relative; } .news-new::after { content: ""; position: absolute; top: 50%; right: 15px; transform: translateY(-50%); width: 8px; height: 8px; background-color: var(--tesla-blue); border-radius: 50%; animation: news-pulse 1.5s 1; animation-fill-mode: forwards; } @keyframes news-pulse { 0% { opacity: 0.7; transform: translateY(-50%) scale(0.7); } 50% { opacity: 1; transform: translateY(-50%) scale(1.3); } 100% { opacity: 1; transform: translateY(-50%) scale(1); } } .news-item:hover { background-color: var(--button-bg); /* Keep the hover color consistent */ } .news-item:last-child { margin-bottom: 0; } .news-source { font-weight: 675; margin-right: 10px; font-size: 11.5pt; } .news-time, .news-date { font-size: 12.5pt; margin-left: 5px; font-weight: 500; } .news-title { color: var(--button-text); margin-top: 0px; padding-bottom: 5px; margin-right: 55px; /* avoid indicators */ font-size: 16pt; font-weight: 550; } .news-favicon { position: absolute; /* Position absolutely within the news-item */ left: 15px; /* Position on the left side with some padding */ top: 50%; /* Center vertically */ transform: translateY(-50%); /* Perfect vertical centering */ width: 32px; /* Slightly increased width */ height: 32px; /* Slightly increased height */ border-radius: 4px; /* Optional: adds a slight rounding to the icons */ opacity: 0.8; } .share-icon { position: absolute; right: 21px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; } body.dark-mode .share-icon img { filter: brightness(0) invert(0.8) grayscale(1); } /* Status Indicators - Unified Styling */ .status-indicator { width: 24px; height: 24px; margin-right: 22px; display: flex; align-items: center; justify-content: center; transition: color 0.3s ease; } /* Network Status Indicator */ .network-status { color: var(--status-unavailable); } .network-status.unavailable { color: var(--status-unavailable); } .network-status.poor { color: var(--status-poor); } .network-status.good { color: var(--status-good); } /* GPS Status Indicator */ .gps-status { color: var(--status-unavailable); } .gps-status.unavailable { color: var(--status-unavailable); } .gps-status.poor { color: var(--status-poor); } .gps-status.good { color: var(--status-good); } /* Rain Status Indicator */ .rain-status { color: var(--weather-warning-color); /* Changed to orange weather warning color */ animation: pulse-rain 2s infinite; } @keyframes pulse-rain { 0% { opacity: 0.7; } 50% { opacity: 1; } 100% { opacity: 0.7; } } /* Notification System */ #notification-container { position: fixed; top: 20px; left: 50%; /* Center horizontally */ transform: translateX(-50%); /* Offset by half width for true centering */ z-index: 9999; max-width: 80%; /* Allow more width */ min-width: 200px; } .notification { display: flex; align-items: center; background-color: rgba(0, 0, 0, 0.75); padding: 12px 16px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); opacity: 0; transform: translateY(-20px); transition: opacity 0.3s, transform 0.3s; white-space: nowrap; /* Keep on one line */ width: auto; /* Size to content */ display: inline-flex; /* Only take needed width */ } .notification.show { opacity: 1; transform: translateY(0); } .notification.hide { opacity: 0; transform: translateY(-20px); } .notification-icon { margin-right: 12px; color: var(--weather-warning-color); /* Match the orange warning color */ } .notification-message { font-size: 14pt; font-weight: 500; color: #ff9500; /* Bright orange color for text */ white-space: nowrap; /* Ensure text stays on one line */ } /* Spinner styling */ .spinner-container { display: flex; justify-content: center; align-items: center; width: 100%; height: 200px; /* Provide adequate height for the spinner */ } .spinner { width: 50px; height: 50px; border: 5px solid rgba(0, 119, 255, 0.2); /* Tesla blue with opacity */ border-radius: 50%; border-top-color: var(--tesla-blue); animation: spin 1s ease-in-out infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Network status indicator */ .network-status svg { width: 24px; height: 24px; } .network-status .network-bar { transition: fill 0.3s ease; } .network-status.unavailable .network-bar { fill: var(--status-unavailable); } .network-status.poor .bar-1 { fill: var(--status-poor); } .network-status.poor .bar-2, .network-status.poor .bar-3, .network-status.poor .bar-4 { fill: var(--status-unavailable); } .network-status.fair .bar-1, .network-status.fair .bar-2 { fill: var(--status-poor); } .network-status.fair .bar-3, .network-status.fair .bar-4 { fill: var(--status-unavailable); } .network-status.good .bar-1, .network-status.good .bar-2, .network-status.good .bar-3 { fill: var(--status-good); } .network-status.good .bar-4 { fill: var(--status-unavailable); } .network-status.excellent .bar-1, .network-status.excellent .bar-2, .network-status.excellent .bar-3, .network-status.excellent .bar-4 { fill: var(--status-good); } /* GPS Status Indicator */ .gps-status { margin-right: 22px; /* Good separation from the Dark Mode text */ width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; transition: color 0.3s ease; } .gps-status.unavailable { color: var(--status-unavailable); /* Using the shared variable */ } .gps-status.poor { color: var(--status-poor); /* Using the shared variable */ } .gps-status.good { color: var(--status-good); /* Using the shared variable */ } /* Login Styles */ .login-button { color: var(--button-text); background-color: var(--button-bg); border-radius: var(--button-radius); font-size: 16pt; font-weight: 600; padding: 16px 20px; border: none; cursor: pointer; text-align: center; margin-top: 15px; width: 100%; transition: background-color 0.3s; } .login-button:hover { background-color: var(--active-section-bg); } /* Modal Login Dialog */ .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); justify-content: center; align-items: center; } .modal-content { font-size: 14pt; background-color: var(--bg-color); border-radius: var(--button-radius); padding: 25px; padding-top: 0px; max-width: 90%; box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.75); } /* Login form */ .login-form { margin-top: 20px; } .login-form label { font-weight: 500; font-size: 14pt; width: 80px; margin-right: 15px; margin-bottom: 10px; color: var(--button-text); vertical-align: middle; } .login-form input { font-size: 16px; width: calc(100% - 90px); padding: 12px 15px; border: 2px solid var(--separator-color); border-radius: 8px; background-color: var(--active-section-bg); color: var(--button-text); box-sizing: border-box; vertical-align: middle; } .button-container { display: flex; justify-content: flex-end; gap: 10px; } .modal-button { padding: 12px 25px; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: background-color 0.3s; } .modal-button.cancel { background-color: var(--button-bg); color: var(--button-text); } .modal-button.submit { background-color: var(--tesla-blue); color: white; } .error-message { color: var(--tesla-red); margin-bottom: 20px; font-size: 14px; min-height: 20px; } /* Settings Section Styles */ .settings-controls { margin-top: -9px; display: grid; grid-template-columns: repeat(auto-fill, 470px); gap: 0px 11px; /* row gap, column gap */ } .settings-toggle-item { display: flex; align-items: center; background-color: var(--button-bg); border-radius: var(--button-radius); padding-left: 15px; padding-right: 15px; padding-top: 12px; padding-bottom: 12px; margin-top: 9px; max-width: 480px; font-weight: 550; cursor: pointer; /* Add cursor pointer to indicate it's clickable */ } .settings-toggle-item label { flex-grow: 1; /* Make label take up all available space */ cursor: inherit; } .settings-toggle-item input { opacity: 0; /* Hide the checkbox */ width: 0; height: 0; position: absolute; } .settings-toggle-item span.settings-toggle-slider { position: relative; display: inline-block; width: 60px; height: 32px; flex-shrink: 0; /* Prevent slider from shrinking */ } .settings-toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #aaa; transition: .4s; border-radius: 30px; } .settings-toggle-slider:before { position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; } .settings-description { margin-top: 2pt; font-size: 12.5pt; font-style: italic; margin-left: 11pt; text-align: right; } input:checked + .settings-toggle-slider { background-color: var(--tesla-blue); } input:focus + .settings-toggle-slider { box-shadow: 0 0 1px var(--tesla-blue); } input:checked + .settings-toggle-slider:before { transform: translateX(26px); } /* Settings Text Item */ .settings-text-item { cursor: initial; /* Prevent cursor change on text input */ } .settings-text-item input[type="text"] { opacity: 100%; width: 320px; height: 19px; position: relative; padding: 8px 14px; border-width: 0px; border-radius: 9px; background: var(--bg-color); color: var(--button-text); font-size: 14pt; font-weight: 500; text-align: right; } body.dark-mode .settings-text-item input[type="text"]:disabled { background: #333333; color: #555555; } .settings-text-item input[type="text"]:focus { outline: var(--tesla-blue) solid 2px; } #in-map-toggle { position: absolute; margin: 0px; padding: 7px; z-index: 10; background-color: transparent; opacity: 80%; } #in-map-toggle .option-switch { background-color: var(--button-bg); } #in-map-toggle .option-button { font-size: 12pt; font-weight: 700; } #in-map-toggle { position: absolute; margin: 0px; padding: 7px; z-index: 10; background-color: transparent; opacity: 80%; } #in-map-toggle .option-switch { background-color: var(--button-bg); } #in-map-toggle .option-button { font-size: 12pt; font-weight: 700; } /* Option Switch (Two-option selector) */ .option-switch-container { padding-right: 15px; /* Ensure there's adequate right padding */ padding-top: 11px; padding-bottom: 11px; cursor: initial; } .option-switch { display: flex; border-radius: calc(var(--button-radius) - 2px); overflow: hidden; background-color: var(--button-highlight); } .option-button { flex: 1; background: none; border: none; padding: 8px 14px; color: var(--text-color); font-size: 14pt; font-weight: 600; cursor: pointer; transition: background-color 0.3s, color 0.3s; } .option-button.active { background-color: var(--tesla-blue); color: white; } .option-button:hover:not(.active) { background-color: rgba(0, 0, 0, 0.05); } body.dark-mode .option-button:hover:not(.active) { background-color: rgba(255, 255, 255, 0.1); } .settings-toggle-item.option-switch-container { display: flex; justify-content: space-between; align-items: center; } /* News Source Grid */ .news-source-grid { grid-template-columns: repeat(auto-fill, 310px); gap: 0px 10px; width: auto; } .news-toggle-item { padding: 9px 15px; margin-bottom: 0px; margin-top: 7px; max-width: none; width: auto; border-radius: 9px; } .news-toggle-item { font-size: 15pt; } /* Mobile Landscape Mode */ @media only screen and (max-width: 900px) and (orientation: landscape) { :root { --heading-font-size: 14pt; --heading-font-weight: 600; --button-radius: 10px; } body { font-size: 13pt; } h1 { font-size: 16pt; margin-bottom: 5px; } h2 { margin-top: 15px; margin-bottom: 7px; } /* Layout adjustment */ .frame-container { flex-direction: column; } /* Convert left menu to horizontal top menu */ .left-frame { width: 100%; height: auto; max-height: 90px; padding: 5px; overflow-y: hidden; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; /* Hide scrollbar for Firefox */ } .left-frame::-webkit-scrollbar { display: none; /* Hide scrollbar for Chrome/Safari */ } /* Convert section buttons to horizontal layout */ .section-buttons { flex-direction: row; min-width: min-content; width: auto; height: 80px; overflow-x: auto; } .section-buttons h1 { display: none; /* Hide title to save space */ } .section-button { font-size: 14pt; padding: 8px 15px; margin: 0 3px; white-space: nowrap; height: 60px; } .button-icon { width: 16px; height: 16px; margin-right: 8px; } /* Right frame takes remaining space */ .right-frame { height: calc(100vh - 90px); padding: 10px 15px; } /* Dashboard adjustments */ .nav-container { gap: 20px; margin: 10px 0px -15px 0px; } .radar-container { padding-top: 20px; margin-right: 15px; } #radarDisplay { width: 150px; height: 150px; } .stat-box { height: 90px; } .stat-value { font-size: 36pt; } .stat-unit { font-size: 18pt; } .stat-label { font-size: 11pt; } /* Teslawaze container adjustment */ .teslawaze-container { height: calc(100vh - 300px); min-height: 200px; } /* Button lists */ .button-list { grid-template-columns: repeat(auto-fill, 220px); gap: 10px; } .button-list a { padding: 15px 5px; height: 30px; font-size: 13pt; } /* News items */ .news-item { padding: 8px 8px 8px 50px; } .news-favicon { width: 26px; height: 26px; } .news-title { font-size: 14pt; margin-right: 40px; } /* Settings adjustments */ .settings-controls { grid-template-columns: repeat(auto-fill, 300px); } .settings-toggle-item { padding: 8px 10px; font-size: 13pt; } .settings-toggle-item span.settings-toggle-slider { width: 50px; height: 26px; } .settings-toggle-slider:before { height: 20px; width: 20px; } input:checked + .settings-toggle-slider:before { transform: translateX(22px); } /* News sources grid */ .news-source-grid { grid-template-columns: repeat(auto-fill, 240px); } .news-toggle-item { font-size: 13pt; } /* Control container (top right) */ .control-container { top: 85px; right: 10px; padding: 5px 10px; } .toggle-label { font-size: 13pt; } /* Login button */ .login-button { margin-top: 10px; padding: 10px 15px; font-size: 14pt; } } PK7)s s PK- $Z timeline.css SV /* Timeline-based hourly forecast styles */ /* Day heading alignment */ .forecast-popup h2 { margin-left: 15pt; } /* Container for the timeline view */ .timeline-container { position: relative; width: 100%; height: 120px; margin-bottom: 40px; user-select: none; } /* Weather condition row */ .timeline-weather-row { display: flex; height: 80px; width: 100%; position: relative; border-radius: var(--button-radius); overflow: hidden; } /* Individual hour in the timeline */ .timeline-hour { flex: 1; height: 100%; position: relative; box-sizing: border-box; } /* Use the existing weather condition gradients */ .timeline-hour.clear { background: linear-gradient(to bottom, var(--sky-clear-top), var(--sky-clear-bottom)); } .timeline-hour.clouds { background: linear-gradient(to bottom, var(--sky-cloudy-top), var(--sky-cloudy-bottom)); } .timeline-hour.rain { background: linear-gradient(to bottom, var(--sky-rainy-top), var(--sky-rainy-bottom)); } .timeline-hour.storm, .timeline-hour.thunderstorm { background: linear-gradient(to bottom, var(--sky-storm-top), var(--sky-storm-bottom)); } .timeline-hour.snow { background: linear-gradient(to bottom, var(--sky-snow-top), var(--sky-snow-bottom)); } .timeline-hour:hover { filter: brightness(1.1); } /* Weather icons */ .weather-icons { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 5; pointer-events: none; } .weather-change-icon { position: absolute; background-color: rgba(255, 255, 255, 0.5); border-radius: 50%; padding: 2px; box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); z-index: 10; transform: translate(-50%, -50%); top: 40px; /* Exact center of the 80px height timeline */ } .weather-change-icon img { width: 40px; height: 40px; vertical-align: middle; } /* Temperature indicators */ .temperature-indicators { position: absolute; bottom: 0; left: 0; width: 100%; height: 0; pointer-events: none; } .temp-indicator { position: absolute; text-align: center; font-weight: 650; transform: translateX(-50%); color: var(--text-color); bottom: 10px; font-size: 14pt; /* Bumped up font size for temperature labels */ } /* Hour labels */ .hour-labels { position: absolute; bottom: 5px; /* Move up even closer to the temperature labels */ left: 0; width: 100%; pointer-events: none; font-weight: 300; } .hour-label { position: absolute; text-align: center; transform: translateX(-50%); color: var(--text-color); font-size: 13pt; /* Bumped up font size for time labels */ } /* Weather legend */ .weather-legend { display: flex; /* flex-wrap: wrap; */ justify-content: center; gap: 10px; /* margin-top: 15px; */ } .legend-item { display: flex; /* align-items: center; */ margin: 0 10px; font-size: 14pt; font-weight: 500; } .legend-color { width: 35px; height: 25px; margin-right: 7px; border-radius: 3px; } .legend-item.clear .legend-color { background: linear-gradient(to bottom, var(--sky-clear-top), var(--sky-clear-bottom)); } .legend-item.clouds .legend-color { background: linear-gradient(to bottom, var(--sky-cloudy-top), var(--sky-cloudy-bottom)); } .legend-item.rain .legend-color { background: linear-gradient(to bottom, var(--sky-rainy-top), var(--sky-rainy-bottom)); } .legend-item.storm .legend-color, .legend-item.thunderstorm .legend-color { background: linear-gradient(to bottom, var(--sky-storm-top), var(--sky-storm-bottom)); } .legend-item.snow .legend-color { background: linear-gradient(to bottom, var(--sky-snow-top), var(--sky-snow-bottom)); } /* Mobile Landscape Mode */ @media only screen and (max-width: 900px) and (orientation: landscape) { .timeline-container { height: 100px; margin-bottom: 35px; } .timeline-weather-row { height: 60px; } .weather-change-icon img { width: 24px; height: 24px; } .temperature-indicators { top: 65px; } .temp-indicator, .hour-label { font-size: 10pt; } .weather-legend { gap: 5px; margin-top: 10px; } .legend-item { margin: 0 5px; font-size: 10pt; } .legend-color { width: 16px; height: 16px; } } PKzs PK- G.Z vers.php SV $gitInfo['commit'], 'branch' => $gitInfo['branch'], 'tag' => $gitInfo['tag'] ]); PKY# PK- G.Z warn.svg SV PKj j PK- Z wx.css SV :root { --sky-clear-top: #e6f7ff; --sky-clear-bottom: #a8d0f0; --sky-cloudy-top: #cacaca; --sky-cloudy-bottom: #70a0c7; --sky-rainy-top: #7a7f8d; --sky-rainy-bottom: #cacaca; --sky-storm-top: #c9d6e2; --sky-storm-bottom: #7b8a9a; --sky-snow-top: #f0f5fb; --sky-snow-bottom: #d8dfe6; } body.dark-mode { --sky-clear-top: #0a1020; --sky-clear-bottom: #441a45; --sky-cloudy-top: #262729; --sky-cloudy-bottom: #142236; --sky-rainy-top: #2a3040; --sky-rainy-bottom: #262729; --sky-storm-top: #080e18; --sky-storm-bottom: #292b2e; --sky-snow-top: #3e3e3b; --sky-snow-bottom: #706e6b; } /* Weather Switch Elements */ .weather-switch-container { display: flex; justify-content: flex-start; margin: 20px 0; } .weather-switch { display: flex; background-color: var(--button-bg); border-radius: var(--button-radius); padding: 4px; gap: 4px; position: relative; width: 420px; } .weather-switch button { flex: 1; padding: 16px 24px; border: none; border-radius: calc(var(--button-radius) - 4px); background: transparent; color: var(--text-color); cursor: pointer; font-family: 'Inter', sans-serif; font-size: 16pt; font-weight: 600; position: relative; z-index: 1; transition: color 0.3s ease; } .weather-switch button.active { color: var(--button-text); } .weather-switch::after { content: ''; position: absolute; top: 4px; left: 4px; bottom: 4px; width: calc((100% - 8px) / 3); background-color: var(--weather-switch-slider); border-radius: calc(var(--button-radius) - 4px); transform: translateX(calc(var(--slider-position) * 100%)); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .weather-image { width: 95%; max-width: 1080px; opacity: 0; display: none; border-radius: var(--button-radius); transition: opacity 0.3s ease; } .weather-image.active { display: block; opacity: 1; } /* Weather Forecast */ .forecast-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(142px, 1fr)); gap: 15px; margin: 18px 0; max-width: 1100px; } .forecast-day { border-radius: var(--button-radius); padding: 15px; text-align: center; position: relative; min-width: 120px; max-width: 180px; } .hourly-avail { /* box-shadow: 5px 5px 9px 0px rgba(0, 0, 0, 0.5); */ border-color: var(--tesla-blue); border-width: 2.5pt; border-style: solid; cursor: pointer; } #wx-data { max-width: 960px; } #minutely-precip-container { max-width: 1100px; } /* Loading spinner */ .forecast-loading { display: flex; /* Initially visible */ justify-content: center; align-items: center; height: 200px; margin: 20px 0; } .spinner { width: 50px; height: 50px; border: 5px solid rgba(0, 0, 0, 0.1); border-radius: 50%; border-top-color: var(--tesla-blue); animation: spin 1s linear infinite; } body.dark-mode .spinner { border-color: rgba(255, 255, 255, 0.1); border-top-color: var(--tesla-blue); } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Weather condition-specific gradients */ .forecast-day.clear { background: linear-gradient(to bottom, var(--sky-clear-top), var(--sky-clear-bottom)); } .forecast-day.clouds { background: linear-gradient(to bottom, var(--sky-cloudy-top), var(--sky-cloudy-bottom)); } .forecast-day.rain { background: linear-gradient(to bottom, var(--sky-rainy-top), var(--sky-rainy-bottom)); } .forecast-day.storm, .forecast-day.thunderstorm { background: linear-gradient(to bottom, var(--sky-storm-top), var(--sky-storm-bottom)); } .forecast-day.snow { background: linear-gradient(to bottom, var(--sky-snow-top), var(--sky-snow-bottom)); } /* Invert snow icon in dark mode */ body.dark-mode .forecast-day.snow img.forecast-icon { filter: invert(1); } /* Invert clear icon in dark mode and make it grayscale */ body.dark-mode .forecast-day.clear img.forecast-icon { filter: invert(1); filter: grayscale(1); } /* Invert rain icon in dark mode and make it grayscale */ body.dark-mode .forecast-day.rain img.forecast-icon { filter: invert(1); filter: grayscale(1); } .forecast-alert { position: absolute; top: 5px; left: 7px; } .forecast-date { font-size: 13pt; font-weight: 600; margin-bottom: 8px; font-size: 12pt; font-weight: 650; text-transform: uppercase; color: var(--button-text); } .forecast-icon { width: 64px; height: 64px; margin: 9px; } .forecast-temp { font-size: 15pt; font-weight: 750; margin: 8px 0; color: var(--button-text); } .forecast-desc { font-family: "Inter", sans-serif; font-size: 14pt; font-style: italic; font-weight: 600; color: var(--button-text); } .forecast-popup { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: var(--active-section-bg); border-radius: var(--button-radius); padding: 20px; z-index: 1000; max-width: 90%; width: 90%; /* Increased width to fit more items */ max-height: 80vh; overflow-y: auto; box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.5); } /* Add a max-width constraint to match our 12-column layout */ @media (min-width: 1200px) { .forecast-popup { max-width: 1180px; /* 1140px (12-column width) + 40px (padding) */ width: 1180px; } } .forecast-popup.show { display: block; } .forecast-popup-close { position: absolute; right: 10px; top: 10px; background: rgb(205 205 205 / 50%); border: none; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 600; line-height: 1; /* font-family: monospace; */ padding-bottom: 4px; padding-left: 7px; color: var(--button-text); transition: all 0.2s ease; } .forecast-popup-close:hover { background: rgba(0, 0, 0, 0.2); color: #333; } .hourly-forecast { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); /* Current setting */ gap: 6px; /* Reduced gap */ margin-top: 15px; max-width: 1140px; /* 90px × 12 + 6px × 11 gaps = 1146px, rounded down slightly */ margin-left: auto; margin-right: auto; } /* Add a media query for larger screens to explicitly limit to 12 columns */ @media (min-width: 1200px) { .hourly-forecast { grid-template-columns: repeat(12, 1fr); /* Force exactly 12 columns on wide screens */ } } .hourly-item { background: linear-gradient(to bottom, var(--sky-gradient-top), var(--sky-gradient-bottom)); padding: 6px; /* Reduced padding */ border-radius: var(--button-radius); text-align: center; transition: background 0.3s ease; } /* Weather condition-specific gradients for hourly items */ .hourly-item.clear { background: linear-gradient(to bottom, var(--sky-clear-top), var(--sky-clear-bottom)); } .hourly-item.clouds { background: linear-gradient(to bottom, var(--sky-cloudy-top), var(--sky-cloudy-bottom)); } .hourly-item.rain { background: linear-gradient(to bottom, var(--sky-rainy-top), var(--sky-rainy-bottom)); } .hourly-item.storm, .hourly-item.thunderstorm { background: linear-gradient(to bottom, var(--sky-storm-top), var(--sky-storm-bottom)); } .hourly-item.snow { background: linear-gradient(to bottom, var(--sky-snow-top), var(--sky-snow-bottom)); } .hourly-time { font-weight: 500; font-size: 11pt; /* Reduced font size */ color: var(--button-text); margin-bottom: 2px; /* Reduced margin */ } .hourly-icon { width: 32px; /* Smaller icon */ height: 32px; /* Smaller icon */ } .hourly-temp { color: var(--button-text); font-size: 11pt; /* Reduced font size */ font-weight: 750; margin: 2px 0; /* Reduced margin */ } .hourly-desc { color: var(--button-text); font-size: 10pt; /* Reduced font size */ font-style: italic; font-weight: 600; white-space: nowrap; /* Prevent text wrapping */ overflow: hidden; /* Hide text that doesn't fit */ text-overflow: ellipsis; /* Add ellipsis for overflow text */ } .station-name { font-size: 11pt; text-transform: uppercase; margin-left: 10px; color: var(--text-color); } /* Moon phase icon */ .moon-phase-icon { display: inline-block; width: 18px; height: 18px; margin-left: 8px; vertical-align: baseline; background-color: #ccc; border-radius: 50%; position: relative; overflow: hidden; } /* Dark mode styles for moon icon */ body.dark-mode .moon-phase-icon { background-color: #555; } /* Mobile Landscape Mode */ @media only screen and (max-width: 900px) and (orientation: landscape) { /* Weather switch elements */ .weather-switch-container { margin: 10px 0; } .weather-switch { width: 320px; } .weather-switch button { padding: 10px 16px; font-size: 14pt; } /* Weather images */ .weather-image { max-width: 95%; } /* Forecast container */ .forecast-container { gap: 10px; margin: 12px 0; max-width: 95%; } .forecast-day { padding: 10px; min-width: 100px; max-width: 140px; } .forecast-date { font-size: 10pt; margin-bottom: 5px; } .forecast-icon { width: 48px; height: 48px; margin: 5px; } .forecast-temp { font-size: 13pt; margin: 5px 0; } .forecast-desc { font-size: 12pt; } /* Forecast popup */ .forecast-popup { width: 90%; max-height: 70vh; } .hourly-forecast { grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); /* Even smaller for landscape */ gap: 5px; } .hourly-item { padding: 5px; } .hourly-time { font-size: 10pt; } .hourly-icon { width: 28px; height: 28px; } .hourly-temp { font-size: 10pt; margin: 1px 0; } .hourly-desc { font-size: 9pt; } /* Moon phase icon */ .moon-phase-icon { width: 14px; height: 14px; } /* Loading spinner */ .forecast-loading { height: 150px; } } /* Notification styles - with dark mode support */ .notification { display: flex; align-items: center; padding: 15px; margin-bottom: 10px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); transform: translateX(120%); transition: transform 0.3s ease; opacity: 0.95; /* Light mode defaults */ background-color: rgba(255, 255, 255, 0.85); color: #cc6600; /* Darker orange for light mode */ } body.dark-mode .notification { background-color: rgba(33, 33, 33, 0.9); color: #ff7700; /* Brighter orange for dark mode */ } .notification.show { transform: translateX(0); } .notification.hide { transform: translateX(120%); } .notification-message { font-size: 18px; font-weight: 700; } /* Cloud icon styles for both status indicator and notification */ .rain-status img, .notification-icon img { filter: invert(50%) sepia(68%) saturate(3233%) hue-rotate(360deg) brightness(103%) contrast(103%); } body.dark-mode .rain-status img, body.dark-mode .notification-icon img { filter: invert(74%) sepia(69%) saturate(5422%) hue-rotate(359deg) brightness(101%) contrast(107%); } /* Add a little spacing between the notification icon and text */ .notification-icon { margin-right: 12px; } PKu*. . PK- Z wx.js SV // Import required functions from app.js import { formatTime, highlightUpdate, testMode } from './common.js'; import { settings, turnOffDarkMode, turnOnDarkMode } from './settings.js'; // Parameters const SAT_URLS = { latest: 'https://cdn.star.nesdis.noaa.gov/GOES19/ABI/CONUS/GEOCOLOR/1250x750.jpg', loop: 'https://cdn.star.nesdis.noaa.gov/GOES16/GLM/CONUS/EXTENT3/GOES16-CONUS-EXTENT3-625x375.gif', latest_ir: 'https://cdn.star.nesdis.noaa.gov/GOES16/ABI/CONUS/11/1250x750.jpg', }; // Module variables let forecastDataPrem = null; // has both current and forecast data let lastLat = null; let lastLong = null; let minutelyPrecipChart = null; let precipGraphUpdateInterval = null; // Timer for updating the precipitation graph let currentRainAlert = false; // Flag to track if we're currently under a rain alert // Export these variables for use in other modules export { SAT_URLS, forecastDataPrem }; // Automatically toggles dark mode based on sunrise and sunset times // TODO: This should really go in the settings module! export function autoDarkMode(lat, long) { // if lat or long are null, then replace with last known values if (lat == null || long == null) { if (lastLat && lastLong) { lat = lastLat; long = lastLong; } else { console.log('autoDarkMode: No coordinates available.'); return; } } console.log('Auto dark mode check for coordinates: ', lat, long); // Get sunrise and sunset times from forecastDataPrem const sunrise = forecastDataPrem?.current.sunrise * 1000; const sunset = forecastDataPrem?.current.sunset * 1000; if (!sunrise || !sunset) { console.log('Auto dark mode: No sunrise/sunset data available.'); return; } if (settings && settings['auto-dark-mode']) { const now = new Date(); const currentTime = now.getTime(); const sunriseTime = new Date(sunrise).getTime(); const sunsetTime = new Date(sunset).getTime(); if (currentTime >= sunsetTime || currentTime < sunriseTime) { console.log('Applying dark mode based on sunset...'); turnOnDarkMode(); } else { console.log('Applying light mode based on sunrise...'); turnOffDarkMode(); } } else { console.log('Auto dark mode disabled or coordinates not available.'); } } // Fetches premium weather data from OpenWeather API export function fetchPremiumWeatherData(lat, long, silentLoad = false) { console.log('Fetching premium weather data...'); // Save so we can call functions later outside GPS update loop, if needed lastLat = lat; lastLong = long; // Show loading spinner, hide forecast container - only if not silent loading const forecastContainer = document.getElementById('prem-forecast-container'); const loadingSpinner = document.getElementById('prem-forecast-loading'); // Remember display style of forecast container let lastDisplayStyle = forecastContainer.style.display; if (!silentLoad) { if (forecastContainer) forecastContainer.style.display = 'none'; if (loadingSpinner) loadingSpinner.style.display = 'flex'; } // Fetch and update weather data (single fetch) fetch(`openwx_proxy.php/data/3.0/onecall?lat=${lat}&lon=${long}&units=imperial`) .then(response => response.json()) .then(forecastDataLocal => { if (forecastDataLocal) { forecastDataPrem = forecastDataLocal; // If in test mode, generate random precipitation data for minutely forecast if (testMode) { console.log('TEST MODE: Generating random precipitation data'); // Create minutely data if it doesn't exist if (!forecastDataPrem.minutely || forecastDataPrem.minutely.length < 60) { forecastDataPrem.minutely = []; // Current timestamp in seconds, minus a random offset of 0-10 minutes const randomOffsetMinutes = Math.floor(Math.random() * 11); // 0-10 minutes const nowSec = Math.floor(Date.now() / 1000) - (randomOffsetMinutes * 60); console.log(`TEST MODE: Setting initial time to ${randomOffsetMinutes} minutes in the past`); // Generate 60 minutes of data for (let i = 0; i < 60; i++) { // First 18 data points have zero precipitation const precipitation = i < 18 ? 0 : Math.random() * 5; forecastDataPrem.minutely.push({ dt: nowSec + (i * 60), precipitation: precipitation }); } } else { // Modify existing minutely data const randomOffsetMinutes = Math.floor(Math.random() * 11); // 0-10 minutes const nowSec = Math.floor(Date.now() / 1000) - (randomOffsetMinutes * 60); console.log(`TEST MODE: Setting initial time to ${randomOffsetMinutes} minutes in the past`); forecastDataPrem.minutely.forEach((minute, index) => { minute.dt = nowSec + (index * 60); // First 18 data points have zero precipitation minute.precipitation = index < 18 ? 0 : Math.random() * 5; }); } // Make sure at least some values are non-zero to trigger display // Set a few minutes after the initial 18 to have definite precipitation for (let i = 25; i < 40; i++) { if (i < forecastDataPrem.minutely.length) { forecastDataPrem.minutely[i].precipitation = 2 + Math.random() * 3; // 2-5 mm/hr } } } updatePremiumWeatherDisplay(); // autoDarkMode(lat, long); // Update time and location of weather data, using FormatTime const weatherUpdateTime = formatTime(new Date(forecastDataLocal.current.dt * 1000), { hour: '2-digit', minute: '2-digit' }); // Get nearest city using OpenWeather GEOlocation API fetch(`openwx_proxy.php/geo/1.0/reverse?lat=${lat}&lon=${long}&limit=1`) .then(response => response.json()) .then(data => { if (data && data.length > 0) { const city = data[0].name; const state = data[0].state; const country = data[0].country; const stationStr = `${city}, ${state} @ ${weatherUpdateTime}`; highlightUpdate('prem-station-info', stationStr); } else { console.log('No location data available.'); } }) .catch(error => { console.error('Error fetching location data: ', error); }); // Start auto-refresh for precipitation graph startPrecipGraphAutoRefresh(); } else { console.log('No premium forecast data available.'); forecastDataPrem = null; } if (lat && long) { updateAQI(lat, long); } // Hide spinner and show forecast when data is loaded - only if not silent loading if (forecastContainer) forecastContainer.style.display = lastDisplayStyle; if (loadingSpinner) loadingSpinner.style.display = 'none'; }) .catch(error => { console.error('Error fetching forecast data: ', error); // In case of error, hide spinner and show error message - only if not silent loading if (!silentLoad) { if (loadingSpinner) loadingSpinner.style.display = 'none'; } }); } // Updates the forecast display with premium data export function updatePremiumWeatherDisplay() { if (!forecastDataPrem) return; // Extract daily summary (first 5 days) const dailyData = extractPremiumDailyForecast(forecastDataPrem.daily || []); const forecastDays = document.querySelectorAll('#prem-forecast-container .forecast-day'); dailyData.forEach((day, index) => { if (index < forecastDays.length) { const date = new Date(day.dt * 1000); const dayElement = forecastDays[index]; const hourlyAvail = index < 2 ? true : false; // Update weather condition class const hourlyClass = hourlyAvail ? 'hourly-avail' : null; const weatherCondition = day.weather[0].main.toLowerCase(); dayElement.className = `forecast-day ${hourlyClass} ${weatherCondition}`; // Update date const dateElement = dayElement.querySelector('.forecast-date'); if (dateElement) { dateElement.textContent = date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); } // Update weather icon const iconElement = dayElement.querySelector('.forecast-icon'); if (iconElement) { iconElement.src = `https://openweathermap.org/img/wn/${day.weather[0].icon}@2x.png`; iconElement.alt = day.weather[0].description; } // Update temperature const tempElement = dayElement.querySelector('.forecast-temp'); if (tempElement) { tempElement.textContent = `${formatTemperature(day.temp.min)}/${formatTemperature(day.temp.max)}`; } // Update description const descElement = dayElement.querySelector('.forecast-desc'); if (descElement) { descElement.textContent = day.weather[0].main; } // Show or hide hazard alert const alertIcon = dayElement.querySelector('.forecast-alert'); if (premiumDayHasHazards(day)) { alertIcon.classList.remove('hidden'); } else { alertIcon.classList.add('hidden'); } // Attach click handler for precipitation graph? if (hourlyAvail) { dayElement.onclick = () => showPremiumPrecipGraph(index); } } }); // Update current conditions (from forecastDataPrem.current) if (forecastDataPrem.current) { const humidity = forecastDataPrem.current.humidity; const windSpeed = forecastDataPrem.current.wind_speed; const windGust = forecastDataPrem.current.wind_gust; const windDir = forecastDataPrem.current.wind_deg; highlightUpdate('prem-humidity', `${humidity}%`); if (windSpeed && windDir !== undefined) { let windText; if (windGust && windGust > windSpeed) { // Show wind-gust format windText = `${formatWindSpeedRange(windSpeed, windGust)} at ${Math.round(windDir)}°`; } else { // Just show regular wind speed if no gust data windText = `${formatWindSpeed(windSpeed)} at ${Math.round(windDir)}°`; } highlightUpdate('prem-wind', windText); } else { highlightUpdate('prem-wind', '--'); } } // Update solar and moon data (from forecastDataPrem.daily[0]) if (forecastDataPrem.daily && forecastDataPrem.daily[0]) { const today = forecastDataPrem.daily[0]; const sunriseTime = formatTime(new Date(today.sunrise * 1000), { timeZoneName: 'short' }); highlightUpdate('prem-sunrise', sunriseTime); const sunsetTime = formatTime(new Date(today.sunset * 1000), { timeZoneName: 'short' }); highlightUpdate('prem-sunset', sunsetTime); if (today.moon_phase !== undefined) { const moonPhase = getMoonPhaseName(today.moon_phase); highlightUpdate('prem-moonphase', moonPhase); // Update the moon icon const moonIcon = document.getElementById('prem-moon-icon'); if (moonIcon) { moonIcon.setAttribute('style', getMoonPhaseIcon(today.moon_phase)); } } } // Update precipitation graph with time-based x-axis updatePrecipitationGraph(); // Hide spinner, show forecast const forecastContainer = document.getElementById('prem-forecast-container'); const loadingSpinner = document.getElementById('prem-forecast-loading'); if (forecastContainer) forecastContainer.classList.remove('hidden'); if (loadingSpinner) loadingSpinner.style.display = 'none'; } // Function to update precipitation graph with current time-based x-axis function updatePrecipitationGraph() { if (!forecastDataPrem || !forecastDataPrem.minutely) return; const minutely = forecastDataPrem.minutely || []; let hasMinutelyPrecip = false; if (minutely.length > 0) { const currentTime = new Date(); console.log(`Updating precipitation graph at: ${currentTime.toLocaleTimeString()}`); // Calculate time offsets relative to now and filter out past times const precipData = minutely.map(m => { const minuteTime = new Date(m.dt * 1000); const timeDiffMinutes = Math.round((minuteTime - currentTime) / (60 * 1000)); return { x: timeDiffMinutes, y: m.precipitation || 0, time: minuteTime }; }).filter(item => item.x >= 0); // Filter out past times (negative values) // Extract data for chart const labels = precipData.map(item => item.x); const values = precipData.map(item => item.y); // Check if any precipitation values are greater than 0 hasMinutelyPrecip = values.some(val => val > 0); // Check for rain in the next 15 minutes and show alert if detected checkImminentRain(minutely); const minutelyContainer = document.getElementById('minutely-precip-container'); const minutelyChartCanvas = document.getElementById('minutely-precip-chart'); if (hasMinutelyPrecip && minutelyContainer && minutelyChartCanvas) { minutelyContainer.style.display = ''; // Draw or update the chart if (minutelyPrecipChart) { minutelyPrecipChart.destroy(); } minutelyPrecipChart = new Chart(minutelyChartCanvas.getContext('2d'), { type: 'bar', data: { labels: labels, datasets: [{ label: 'Precipitation (mm/hr)', data: values, backgroundColor: 'rgba(255, 119, 0, 0.6)' }] }, options: { plugins: { legend: { display: false } }, scales: { x: { title: { display: true, text: 'Minutes from now', font: { size: 22, weight: 650 } }, ticks: { font: { size: 18 }, callback: function(value) { // Format as "+" for future minutes return "+" + value; } } }, y: { title: { display: true, text: 'Precipitation (mm/hr)', font: { size: 22, weight: 650 } }, beginAtZero: true, ticks: { font: { size: 18 } } } }, animation: { duration: 200 // Fast animation for updates } } }); } else { // Hide the graph if no precipitation data if (minutelyContainer) minutelyContainer.style.display = 'none'; if (minutelyPrecipChart) { minutelyPrecipChart.destroy(); minutelyPrecipChart = null; } // Don't stop the timer here - we should keep checking for precipitation // Just log that there's no data currently console.log('No precipitation data to display, but continuing to monitor'); } } else { // If no minutely data available, hide the rain indicator toggleRainIndicator(false); // Don't stop the refresh here - data might become available later console.log('No minutely precipitation data available, continuing to monitor'); } // Return true if the refresh should continue return true; } // Function to start auto-refresh for precipitation graph function startPrecipGraphAutoRefresh() { // Clear any existing interval first clearInterval(precipGraphUpdateInterval); console.log('Starting precipitation graph auto-refresh'); // Initial update // updatePrecipitationGraph(); // Set up interval to update every 30 seconds precipGraphUpdateInterval = setInterval(() => { // Log refresh state console.log('Running precipitation graph refresh check...'); updatePrecipitationGraph(); }, 30000); // Update every 30 seconds } // New function: Check for imminent rain (next 15 minutes) function checkImminentRain(minutelyData) { if (!minutelyData || minutelyData.length === 0) { toggleRainIndicator(false); currentRainAlert = false; // Reset alert flag when no data return false; } // Get current time const currentTime = new Date(); // Filter and process only the next 15 minutes of data const next15MinData = minutelyData.filter(minute => { const minuteTime = new Date(minute.dt * 1000); const timeDiffMinutes = (minuteTime - currentTime) / (60 * 1000); // Include only future times within the next 15 minutes return timeDiffMinutes >= 0 && timeDiffMinutes <= 15; }); // Determine if any precipitation is expected in the next 15 minutes // Using a small threshold to filter out trace amounts const precipThreshold = 0.1; // mm/hr const hasImminentRain = next15MinData.some(minute => (minute.precipitation || 0) > precipThreshold ); // Toggle the rain indicator based on our findings toggleRainIndicator(hasImminentRain); // If rain is imminent and we don't have an active alert already, show a notification if (hasImminentRain && !currentRainAlert) { // Calculate when rain will start (first minute above threshold) const rainStartIndex = next15MinData.findIndex(minute => (minute.precipitation || 0) > precipThreshold ); // Find the maximum precipitation intensity in the next 15 minutes const maxPrecip = Math.max(...next15MinData.map(minute => minute.precipitation || 0)); // Create the notification message let message; if (rainStartIndex === 0) { message = `Rain detected now! (${maxPrecip.toFixed(1)} mm/hr)`; } else if (rainStartIndex > 0) { const minuteTime = new Date(next15MinData[rainStartIndex].dt * 1000); const minutesUntilRain = Math.round((minuteTime - currentTime) / (60 * 1000)); message = `Rain expected in ${minutesUntilRain} minute${minutesUntilRain > 1 ? 's' : ''} (${maxPrecip.toFixed(1)} mm/hr)`; } if (message) { // Show the notification showNotification(message); // Set flag that we're under an active rain alert currentRainAlert = true; } } else if (!hasImminentRain) { // Reset the alert flag when there's no longer imminent rain currentRainAlert = false; } return hasImminentRain; } // New function: Toggle the rain indicator function toggleRainIndicator(show) { // Get or create the rain indicator element let rainIndicator = document.getElementById('rain-indicator'); if (!rainIndicator && show) { // Create the rain indicator if it doesn't exist rainIndicator = document.createElement('div'); rainIndicator.id = 'rain-indicator'; rainIndicator.className = 'status-indicator rain-status'; rainIndicator.title = 'Rain expected within 15 minutes'; // Add img element for cloud icon using the external SVG file rainIndicator.innerHTML = `Detailed hourly forecast is only available for the next 2 days.
Detailed hourly forecast is only available for the next 48 hours.
No hourly data available for this day.