diff --git a/assets/js/app.js b/assets/js/app.ts similarity index 68% rename from assets/js/app.js rename to assets/js/app.ts index 06c394c..208108c 100644 --- a/assets/js/app.js +++ b/assets/js/app.ts @@ -1,51 +1,59 @@ -// If you want to use Phoenix channels, run `mix help phx.gen.channel` -// to get started and then uncomment the line below. -// import "./user_socket.js" - -// You can include dependencies in two ways. -// -// The simplest option is to put them in assets/vendor and -// import them using relative paths: -// -// import "../vendor/some-package.js" -// -// Alternatively, you can `npm install some-package --prefix assets` and import -// them using a path starting with the package name: -// -// import "some-package" -// - // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. import "phoenix_html"; // Establish Phoenix Socket and LiveView configuration. import { Socket } from "phoenix"; import { LiveSocket } from "phoenix_live_view"; + +declare global { + interface Window { + topbar: { + config: (opts: { barColors: Record; shadowColor: string }) => void; + show: (delay?: number) => void; + hide: () => void; + }; + mapBundleLoaded?: boolean; + chartBundleLoaded?: boolean; + Chart?: unknown; + VendorLoader?: { + mapBundleUrl: string; + loadCharts: () => void; + }; + liveSocket: any; + Hooks: Record; + } +} + // topbar is loaded globally from vendor bundle const topbar = window.topbar; - -let csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || ""; +const csrfToken = + document + .querySelector("meta[name='csrf-token']") + ?.getAttribute("content") || ""; if (!csrfToken) { console.error("CSRF token not found in meta tags"); } -// Import minimal APRS map hook +// Import hooks import MapAPRSMap from "./map"; -// Import error boundary hook import ErrorBoundary from "./hooks/error_boundary"; -// Import info map hook import { InfoMap } from "./hooks/info_map"; -// Import time ago hook import TimeAgoHook from "./hooks/time_ago_hook"; -// APRS MapAPRSMap Hook -let Hooks = {}; +interface HookDef { + mounted?: () => void; + updated?: () => void; + destroyed?: () => void; + [key: string]: unknown; +} + +const Hooks: Record = {}; // Singleton map bundle loader — ensures the script is only appended once -let mapBundleCallbacks = []; +let mapBundleCallbacks: Array<() => void> = []; let mapBundleLoading = false; -function loadMapBundle(callback) { +function loadMapBundle(callback: () => void) { if (window.mapBundleLoaded) { callback(); return; @@ -54,19 +62,19 @@ function loadMapBundle(callback) { mapBundleCallbacks.push(callback); if (mapBundleLoading) { - return; // Already loading, callback will fire when ready + return; } if (window.VendorLoader) { mapBundleLoading = true; - const script = document.createElement('script'); + const script = document.createElement("script"); script.src = window.VendorLoader.mapBundleUrl; script.onload = () => { window.mapBundleLoaded = true; mapBundleLoading = false; const cbs = mapBundleCallbacks; mapBundleCallbacks = []; - cbs.forEach(cb => cb()); + cbs.forEach((cb) => cb()); }; script.onerror = () => { console.error("Failed to load map bundle"); @@ -90,7 +98,7 @@ Hooks.APRSMap = { originalMapMounted.call(self); } }); - } + }, }; Hooks.InfoMap = { @@ -102,21 +110,19 @@ Hooks.InfoMap = { InfoMap.mounted.call(self); } }); - } + }, }; // Chart hooks - load chart bundle when needed import { WeatherChartHooks } from "./features/weather_charts"; -Object.keys(WeatherChartHooks).forEach(hookName => { - const originalHook = WeatherChartHooks[hookName]; +Object.keys(WeatherChartHooks).forEach((hookName) => { + const originalHook = (WeatherChartHooks as Record)[hookName]; Hooks[hookName] = { ...originalHook, mounted() { const self = this; if (window.VendorLoader && !window.chartBundleLoaded) { - // Load chart bundle and wait for it to complete window.VendorLoader.loadCharts(); - // Wait a bit for charts to load, then call mounted const checkChartLoaded = () => { if (window.Chart) { if (originalHook.mounted) { @@ -128,12 +134,11 @@ Object.keys(WeatherChartHooks).forEach(hookName => { }; setTimeout(checkChartLoaded, 100); } else { - // Chart bundle already loaded or loading, call mounted if (originalHook.mounted) { originalHook.mounted.call(this); } } - } + }, }; }); @@ -142,12 +147,15 @@ Hooks.ErrorBoundary = ErrorBoundary; Hooks.TimeAgoHook = TimeAgoHook; // Theme management -const applyTheme = (theme) => { +const applyTheme = (theme: string | null) => { const element = document.documentElement; if (!element) return; - if (theme === "auto") { - if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { + if (theme === "auto" || !theme) { + if ( + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { element.setAttribute("data-theme", "dark"); } else { element.setAttribute("data-theme", "light"); @@ -161,52 +169,56 @@ const applyTheme = (theme) => { applyTheme(localStorage.getItem("theme") || "auto"); // Handle theme changes dispatched from LiveView via JS.dispatch -window.addEventListener("phx:set-theme", (e) => { +window.addEventListener("phx:set-theme", ((e: CustomEvent<{ theme: string }>) => { const theme = e.detail.theme; applyTheme(theme); localStorage.setItem("theme", theme); window.dispatchEvent(new CustomEvent("themeChanged")); -}); +}) as EventListener); // Listen for system theme changes when auto is selected -window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { - if (localStorage.getItem("theme") === "auto") { - applyTheme("auto"); - window.dispatchEvent(new CustomEvent("themeChanged")); - } -}); +window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", () => { + if (localStorage.getItem("theme") === "auto") { + applyTheme("auto"); + window.dispatchEvent(new CustomEvent("themeChanged")); + } + }); -let liveSocket = new LiveSocket("/live", Socket, { +const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, viewport_width: window.innerWidth }, hooks: Hooks, - timeout: 60000, // 60 second timeout for slow initial loads + timeout: 60000, }); // Show progress bar on live navigation and form submits -topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +topbar.config({ + barColors: { 0: "#29d" }, + shadowColor: "rgba(0, 0, 0, .3)", +}); window.addEventListener("phx:page-loading-start", (_info) => topbar.show(100)); window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); // Handle connection draining reconnect events -window.addEventListener("phx:reconnect", (e) => { +window.addEventListener("phx:reconnect", ((e: CustomEvent<{ delay?: number }>) => { const delay = e.detail.delay || 1000; setTimeout(() => { - // Disconnect and reconnect to potentially land on a different server liveSocket.disconnect(); setTimeout(() => { liveSocket.connect(); }, 100); }, delay); -}); +}) as EventListener); // connect if there are any LiveViews on the page liveSocket.connect(); // Workaround for Phoenix LiveView bug where fallback timer isn't cleared // after successful WebSocket connection -window.addEventListener("phx:live_socket:connect", (info) => { - const socket = liveSocket.socket; +window.addEventListener("phx:live_socket:connect", (_info) => { + const socket = (liveSocket as any).socket; if (socket && socket.fallbackTimer) { clearTimeout(socket.fallbackTimer); socket.fallbackTimer = null; @@ -215,7 +227,7 @@ window.addEventListener("phx:live_socket:connect", (info) => { // Also check periodically in case the event doesn't fire setTimeout(() => { - const socket = liveSocket.socket; + const socket = (liveSocket as any).socket; if (socket && socket.isConnected() && socket.fallbackTimer) { clearTimeout(socket.fallbackTimer); socket.fallbackTimer = null; diff --git a/assets/js/hooks/error_boundary.js b/assets/js/hooks/error_boundary.js deleted file mode 100644 index 90c02f5..0000000 --- a/assets/js/hooks/error_boundary.js +++ /dev/null @@ -1,111 +0,0 @@ -// Error Boundary Hook for Phoenix LiveView -// Catches JavaScript errors and displays fallback content - -const ErrorBoundary = { - mounted() { - // Get content and fallback elements - this.content = this.el.querySelector('.error-boundary-content'); - this.fallback = this.el.querySelector('.error-boundary-fallback'); - - // Create bound error handlers - this.errorHandler = this.handleGlobalError.bind(this); - this.unhandledRejectionHandler = this.handleUnhandledRejection.bind(this); - - // Set up error handlers for this component - this.setupErrorHandlers(); - }, - - setupErrorHandlers() { - // Use event listeners instead of overriding global handlers - window.addEventListener('error', this.errorHandler, true); - window.addEventListener('unhandledrejection', this.unhandledRejectionHandler, true); - }, - - handleGlobalError(event) { - // Extract error from ErrorEvent - const error = event.error || new Error(event.message); - - if (this.isErrorInComponent(error)) { - this.handleError(error); - event.preventDefault(); - event.stopPropagation(); - } - }, - - handleUnhandledRejection(event) { - if (this.isErrorInComponent(event.reason)) { - this.handleError(event.reason); - event.preventDefault(); - event.stopPropagation(); - } - }, - - isErrorInComponent(error) { - // Check if the error occurred within this component's DOM tree - if (!error) return false; - - // If the error has a target element, check if it's within our component - if (error.target && this.el.contains(error.target)) { - return true; - } - - // Check if the error stack trace contains references to our component's hooks or elements - if (error.stack) { - // Look for our component's ID in the stack trace - const componentId = this.el.id; - if (componentId && error.stack.includes(componentId)) { - return true; - } - - // Check if the error originated from a hook within this component - const hooks = this.el.querySelectorAll('[phx-hook]'); - for (let hook of hooks) { - const hookName = hook.getAttribute('phx-hook'); - if (hookName && error.stack.includes(hookName)) { - return true; - } - } - } - - // For now, we'll be conservative and only handle errors we're sure about - // In a more sophisticated implementation, you might analyze the error source - return false; - }, - - handleError(error) { - console.error('Error caught by boundary:', error); - - // Hide content and show fallback - if (this.content && this.content.style) { - this.content.style.display = 'none'; - } - if (this.fallback && this.fallback.classList) { - this.fallback.classList.remove('hidden'); - } - - // Log error to server if pushEvent is available - if (this.pushEvent && typeof this.pushEvent === 'function') { - try { - this.pushEvent('error_boundary_triggered', { - message: error?.message || 'Unknown error', - stack: error?.stack || 'No stack trace', - component_id: this.el?.id || 'unknown' - }); - } catch (e) { - console.error('Failed to send error to server:', e); - } - } - }, - - destroyed() { - // Remove event listeners - if (this.errorHandler) { - window.removeEventListener('error', this.errorHandler, true); - } - if (this.unhandledRejectionHandler) { - window.removeEventListener('unhandledrejection', this.unhandledRejectionHandler, true); - } - } -}; - -export default ErrorBoundary; \ No newline at end of file diff --git a/assets/js/hooks/error_boundary.ts b/assets/js/hooks/error_boundary.ts new file mode 100644 index 0000000..f74a269 --- /dev/null +++ b/assets/js/hooks/error_boundary.ts @@ -0,0 +1,129 @@ +// Error Boundary Hook for Phoenix LiveView +// Catches JavaScript errors and displays fallback content + +interface ErrorBoundaryContext { + el: HTMLElement; + content: HTMLElement | null; + fallback: HTMLElement | null; + errorHandler: (event: ErrorEvent) => void; + unhandledRejectionHandler: (event: PromiseRejectionEvent) => void; + pushEvent?: (event: string, payload: Record) => void; +} + +interface ErrorLike { + message?: string; + stack?: string; + target?: EventTarget | null; +} + +const ErrorBoundary = { + mounted(this: ErrorBoundaryContext) { + this.content = this.el.querySelector(".error-boundary-content"); + this.fallback = this.el.querySelector(".error-boundary-fallback"); + + this.errorHandler = handleGlobalError.bind(this); + this.unhandledRejectionHandler = handleUnhandledRejection.bind(this); + + window.addEventListener("error", this.errorHandler, true); + window.addEventListener( + "unhandledrejection", + this.unhandledRejectionHandler, + true, + ); + }, + + destroyed(this: ErrorBoundaryContext) { + if (this.errorHandler) { + window.removeEventListener("error", this.errorHandler, true); + } + if (this.unhandledRejectionHandler) { + window.removeEventListener( + "unhandledrejection", + this.unhandledRejectionHandler, + true, + ); + } + }, +}; + +function handleGlobalError(this: ErrorBoundaryContext, event: ErrorEvent) { + const error: ErrorLike = event.error || { message: event.message }; + + if (isErrorInComponent.call(this, error)) { + handleError.call(this, error); + event.preventDefault(); + event.stopPropagation(); + } +} + +function handleUnhandledRejection( + this: ErrorBoundaryContext, + event: PromiseRejectionEvent, +) { + const error = event.reason as ErrorLike | undefined; + if (isErrorInComponent.call(this, error)) { + handleError.call(this, error); + event.preventDefault(); + event.stopPropagation(); + } +} + +function isErrorInComponent( + this: ErrorBoundaryContext, + error: ErrorLike | undefined, +): boolean { + if (!error) return false; + + if ( + error.target && + error.target instanceof Node && + this.el.contains(error.target) + ) { + return true; + } + + if (error.stack) { + const componentId = this.el.id; + if (componentId && error.stack.includes(componentId)) { + return true; + } + + const hooks = this.el.querySelectorAll("[phx-hook]"); + for (const hook of hooks) { + const hookName = hook.getAttribute("phx-hook"); + if (hookName && error.stack.includes(hookName)) { + return true; + } + } + } + + return false; +} + +function handleError( + this: ErrorBoundaryContext, + error: ErrorLike | undefined, +) { + console.error("Error caught by boundary:", error); + + if (this.content) { + this.content.style.display = "none"; + } + if (this.fallback) { + this.fallback.classList.remove("hidden"); + } + + if (this.pushEvent && typeof this.pushEvent === "function") { + try { + this.pushEvent("error_boundary_triggered", { + message: error?.message || "Unknown error", + stack: error?.stack || "No stack trace", + component_id: this.el?.id || "unknown", + }); + } catch (e) { + console.error("Failed to send error to server:", e); + } + } +} + +export default ErrorBoundary; diff --git a/assets/js/hooks/info_map.js b/assets/js/hooks/info_map.js deleted file mode 100644 index 0f81630..0000000 --- a/assets/js/hooks/info_map.js +++ /dev/null @@ -1,168 +0,0 @@ -// Simple map hook for displaying a single station on the info page -export const InfoMap = { - mounted() { - this.initializeMap(); - }, - - updated() { - // When the element updates, check if we need to update the marker - const lat = parseFloat(this.el.dataset.lat); - const lon = parseFloat(this.el.dataset.lon); - const symbolHtml = this.el.dataset.symbolHtml; - - // Validate coordinates - const callsign = this.el.dataset.callsign; - if (isNaN(lat) || isNaN(lon)) { - console.warn(`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign} during update`); - return; - } - - // If map doesn't exist yet, initialize it - if (!this.map || !this.map._container || !this.map._container.parentNode) { - // Map was destroyed or removed from DOM, reinitialize - this.initializeMap(); - return; - } - - // Update marker position if it changed - if (this.marker) { - const currentPos = this.marker.getLatLng(); - if (currentPos.lat !== lat || currentPos.lng !== lon) { - // Animate the marker to the new position - this.marker.setLatLng([lat, lon]); - - // Update the popup content - const callsign = this.el.dataset.callsign; - this.marker.setPopupContent(`${callsign}
Lat: ${lat.toFixed(6)}
Lon: ${lon.toFixed(6)}`); - - // Optionally pan the map to the new position with animation - this.map.panTo([lat, lon], { animate: true, duration: 1 }); - } - - // Update marker icon if symbol changed - if (symbolHtml !== this.lastSymbolHtml) { - const markerIcon = this.createMarkerIcon(symbolHtml); - this.marker.setIcon(markerIcon); - this.lastSymbolHtml = symbolHtml; - } - } - }, - - initializeMap() { - // Prevent multiple simultaneous initializations - if (this.initializing) { - return; - } - - // Check if Leaflet is available - if (typeof L === "undefined") { - console.error("Leaflet not loaded for InfoMap"); - return; - } - - this.initializing = true; - - // Get data from element attributes - const lat = parseFloat(this.el.dataset.lat); - const lon = parseFloat(this.el.dataset.lon); - const zoom = parseInt(this.el.dataset.zoom) || 13; - const callsign = this.el.dataset.callsign; - const symbolHtml = this.el.dataset.symbolHtml; - - // Validate coordinates - if (isNaN(lat) || isNaN(lon)) { - console.warn(`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign}`); - return; - } - - // Hide loading spinner - const loadingEl = this.el.querySelector(`#${this.el.id}-loading`); - if (loadingEl) { - loadingEl.style.display = 'none'; - } - - // Initialize the map - try { - this.map = L.map(this.el, { - center: [lat, lon], - zoom: zoom, - scrollWheelZoom: false, // Disable scroll zoom for embedded maps - zoomControl: true, - attributionControl: true - }); - - // Add tile layer - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap', - maxZoom: 19 - }).addTo(this.map); - - // Create marker icon - const markerIcon = this.createMarkerIcon(symbolHtml); - this.lastSymbolHtml = symbolHtml; - - // Add marker for the station - this.marker = L.marker([lat, lon], { icon: markerIcon }) - .addTo(this.map) - .bindPopup(`${callsign}
Lat: ${lat.toFixed(6)}
Lon: ${lon.toFixed(6)}`); - - // Invalidate size after a short delay to ensure proper rendering - this.resizeTimer = setTimeout(() => { - if (this.map) { - this.map.invalidateSize(); - } - this.resizeTimer = null; - }, 250); - - // Mark initialization as complete - this.initializing = false; - - } catch (error) { - console.error("Error initializing InfoMap:", error); - this.initializing = false; - } - }, - - createMarkerIcon(symbolHtml) { - if (symbolHtml) { - // Use the APRS symbol if provided - return L.divIcon({ - html: symbolHtml, - className: 'aprs-info-marker', - iconSize: [32, 32], - iconAnchor: [16, 16] - }); - } else { - // Default marker - return L.divIcon({ - html: `
`, - className: '', - iconSize: [24, 24], - iconAnchor: [12, 12] - }); - } - }, - - destroyed() { - // Cancel pending resize timer - if (this.resizeTimer) { - clearTimeout(this.resizeTimer); - this.resizeTimer = null; - } - // Clean up map and reset state - if (this.map) { - this.map.remove(); - this.map = null; - } - this.marker = null; - this.lastSymbolHtml = null; - this.initializing = false; - } -}; \ No newline at end of file diff --git a/assets/js/hooks/info_map.ts b/assets/js/hooks/info_map.ts new file mode 100644 index 0000000..5e3f0e3 --- /dev/null +++ b/assets/js/hooks/info_map.ts @@ -0,0 +1,174 @@ +// Simple map hook for displaying a single station on the info page + +declare const L: typeof import("leaflet"); + +interface InfoMapContext { + el: HTMLElement; + map: import("leaflet").Map | null; + marker: import("leaflet").Marker | null; + lastSymbolHtml: string | null; + initializing: boolean; + resizeTimer: ReturnType | null; +} + +export const InfoMap = { + mounted(this: InfoMapContext) { + this.map = null; + this.marker = null; + this.lastSymbolHtml = null; + this.initializing = false; + this.resizeTimer = null; + initializeMap.call(this); + }, + + updated(this: InfoMapContext) { + const lat = parseFloat(this.el.dataset.lat || ""); + const lon = parseFloat(this.el.dataset.lon || ""); + const symbolHtml = this.el.dataset.symbolHtml || null; + const callsign = this.el.dataset.callsign || "unknown"; + + if (isNaN(lat) || isNaN(lon)) { + console.warn( + `InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign} during update`, + ); + return; + } + + if ( + !this.map || + !(this.map as any)._container || + !(this.map as any)._container.parentNode + ) { + initializeMap.call(this); + return; + } + + if (this.marker) { + const currentPos = this.marker.getLatLng(); + if (currentPos.lat !== lat || currentPos.lng !== lon) { + this.marker.setLatLng([lat, lon]); + + this.marker.setPopupContent( + `${callsign}
Lat: ${lat.toFixed(6)}
Lon: ${lon.toFixed(6)}`, + ); + + this.map!.panTo([lat, lon], { animate: true, duration: 1 }); + } + + if (symbolHtml !== this.lastSymbolHtml) { + const markerIcon = createMarkerIcon(symbolHtml); + this.marker.setIcon(markerIcon); + this.lastSymbolHtml = symbolHtml; + } + } + }, + + destroyed(this: InfoMapContext) { + if (this.resizeTimer) { + clearTimeout(this.resizeTimer); + this.resizeTimer = null; + } + if (this.map) { + this.map.remove(); + this.map = null; + } + this.marker = null; + this.lastSymbolHtml = null; + this.initializing = false; + }, +}; + +function initializeMap(this: InfoMapContext) { + if (this.initializing) return; + + if (typeof L === "undefined") { + console.error("Leaflet not loaded for InfoMap"); + return; + } + + this.initializing = true; + + const lat = parseFloat(this.el.dataset.lat || ""); + const lon = parseFloat(this.el.dataset.lon || ""); + const zoom = parseInt(this.el.dataset.zoom || "13") || 13; + const callsign = this.el.dataset.callsign || "unknown"; + const symbolHtml = this.el.dataset.symbolHtml || null; + + if (isNaN(lat) || isNaN(lon)) { + console.warn( + `InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign}`, + ); + this.initializing = false; + return; + } + + const loadingEl = this.el.querySelector( + `#${this.el.id}-loading`, + ) as HTMLElement | null; + if (loadingEl) { + loadingEl.style.display = "none"; + } + + try { + this.map = L.map(this.el, { + center: [lat, lon], + zoom: zoom, + scrollWheelZoom: false, + zoomControl: true, + attributionControl: true, + }); + + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: + '© OpenStreetMap', + maxZoom: 19, + }).addTo(this.map); + + const markerIcon = createMarkerIcon(symbolHtml); + this.lastSymbolHtml = symbolHtml; + + this.marker = L.marker([lat, lon], { icon: markerIcon }) + .addTo(this.map) + .bindPopup( + `${callsign}
Lat: ${lat.toFixed(6)}
Lon: ${lon.toFixed(6)}`, + ); + + const map = this.map; + this.resizeTimer = setTimeout(() => { + if (map) { + map.invalidateSize(); + } + this.resizeTimer = null; + }, 250); + + this.initializing = false; + } catch (error) { + console.error("Error initializing InfoMap:", error); + this.initializing = false; + } +} + +function createMarkerIcon(symbolHtml: string | null): import("leaflet").DivIcon { + if (symbolHtml) { + return L.divIcon({ + html: symbolHtml, + className: "aprs-info-marker", + iconSize: [32, 32], + iconAnchor: [16, 16], + }); + } else { + return L.divIcon({ + html: `
`, + className: "", + iconSize: [24, 24], + iconAnchor: [12, 12], + }); + } +} diff --git a/assets/js/hooks/time_ago_hook.js b/assets/js/hooks/time_ago_hook.js deleted file mode 100644 index b8d30a5..0000000 --- a/assets/js/hooks/time_ago_hook.js +++ /dev/null @@ -1,69 +0,0 @@ -// Hook to automatically update time ago displays -export default { - mounted() { - this.startTimer(); - }, - - destroyed() { - this.stopTimer(); - }, - - updated() { - // Restart timer when the element updates - this.stopTimer(); - this.startTimer(); - }, - - startTimer() { - const updateTimeAgo = () => { - const timestampStr = this.el.dataset.timestamp; - if (!timestampStr) return; - - const timestamp = new Date(timestampStr); - const now = new Date(); - const diffMs = now - timestamp; - const diffSeconds = Math.floor(diffMs / 1000); - - if (diffSeconds < 60) { - this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? 's' : ''} ago`; - } else if (diffSeconds < 3600) { - const minutes = Math.floor(diffSeconds / 60); - this.el.textContent = `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; - } else if (diffSeconds < 86400) { - const hours = Math.floor(diffSeconds / 3600); - this.el.textContent = `${hours} hour${hours !== 1 ? 's' : ''} ago`; - } else { - const days = Math.floor(diffSeconds / 86400); - this.el.textContent = `${days} day${days !== 1 ? 's' : ''} ago`; - } - }; - - // Update immediately - updateTimeAgo(); - - // Update every second for recent timestamps, less frequently for older ones - const timestampStr = this.el.dataset.timestamp; - if (timestampStr) { - const timestamp = new Date(timestampStr); - const age = Date.now() - timestamp; - - let interval; - if (age < 60000) { // Less than 1 minute old - interval = 1000; // Update every second - } else if (age < 3600000) { // Less than 1 hour old - interval = 60000; // Update every minute - } else { - interval = 300000; // Update every 5 minutes - } - - this.timer = setInterval(updateTimeAgo, interval); - } - }, - - stopTimer() { - if (this.timer) { - clearInterval(this.timer); - this.timer = null; - } - } -}; \ No newline at end of file diff --git a/assets/js/hooks/time_ago_hook.ts b/assets/js/hooks/time_ago_hook.ts new file mode 100644 index 0000000..cd28b2d --- /dev/null +++ b/assets/js/hooks/time_ago_hook.ts @@ -0,0 +1,75 @@ +// Hook to automatically update time ago displays + +interface TimeAgoHookContext { + el: HTMLElement; + timer: ReturnType | null; +} + +const TimeAgoHook = { + mounted(this: TimeAgoHookContext) { + this.timer = null; + startTimer.call(this); + }, + + destroyed(this: TimeAgoHookContext) { + stopTimer.call(this); + }, + + updated(this: TimeAgoHookContext) { + stopTimer.call(this); + startTimer.call(this); + }, +}; + +function startTimer(this: TimeAgoHookContext) { + const updateTimeAgo = () => { + const timestampStr = this.el.dataset.timestamp; + if (!timestampStr) return; + + const timestamp = new Date(timestampStr); + const now = new Date(); + const diffMs = now.getTime() - timestamp.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + + if (diffSeconds < 60) { + this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? "s" : ""} ago`; + } else if (diffSeconds < 3600) { + const minutes = Math.floor(diffSeconds / 60); + this.el.textContent = `${minutes} minute${minutes !== 1 ? "s" : ""} ago`; + } else if (diffSeconds < 86400) { + const hours = Math.floor(diffSeconds / 3600); + this.el.textContent = `${hours} hour${hours !== 1 ? "s" : ""} ago`; + } else { + const days = Math.floor(diffSeconds / 86400); + this.el.textContent = `${days} day${days !== 1 ? "s" : ""} ago`; + } + }; + + updateTimeAgo(); + + const timestampStr = this.el.dataset.timestamp; + if (timestampStr) { + const timestamp = new Date(timestampStr); + const age = Date.now() - timestamp.getTime(); + + let interval: number; + if (age < 60000) { + interval = 1000; + } else if (age < 3600000) { + interval = 60000; + } else { + interval = 300000; + } + + this.timer = setInterval(updateTimeAgo, interval); + } +} + +function stopTimer(this: TimeAgoHookContext) { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } +} + +export default TimeAgoHook; diff --git a/config/config.exs b/config/config.exs index f3c8c06..8376ab9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -81,10 +81,10 @@ config :error_tracker, # Configure esbuild (the version is required) config :esbuild, - version: "0.24.2", + version: "0.25.4", default: [ args: - ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --loader:.css=css --loader:.png=file --loader:.svg=file), + ~w(js/app.ts --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --loader:.css=css --loader:.png=file --loader:.svg=file), cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ], diff --git a/config/prod.exs b/config/prod.exs index 7abf8ea..8312099 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -16,10 +16,10 @@ config :aprsme, AprsmeWeb.Endpoint, http: [ip: {0, 0, 0, 0}, port: 4000] config :esbuild, - version: "0.24.2", + version: "0.25.4", default: [ args: - ~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --loader:.ts=ts --loader:.css=css --loader:.png=file --loader:.svg=file --external:/fonts/* --external:/images/*), + ~w(js/app.ts --bundle --target=es2020 --outdir=../priv/static/assets --loader:.css=css --loader:.png=file --loader:.svg=file --external:/fonts/* --external:/images/*), cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ] diff --git a/mix.exs b/mix.exs index 1883404..d13dc71 100644 --- a/mix.exs +++ b/mix.exs @@ -81,7 +81,7 @@ defmodule Aprsme.MixProject do {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, aprs_dep(), - {:esbuild, "~> 0.9", runtime: Mix.env() == :dev}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.4.0", runtime: Mix.env() == :dev}, {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.1.1", sparse: "optimized", app: false, compile: false, depth: 1},