Replace JS hooks with LiveView patterns for map page styling, slideover, and theme switching

- Remove BodyClassHook: use CSS :has() selectors on data-map-page attribute
  instead of JS toggling body.map-page class
- Remove ResponsiveSlideoverHook: pass viewport_width via LiveSocket connect
  params and determine initial slideover state server-side
- Convert theme selector from data-set-theme attributes with DOMContentLoaded
  listeners to JS.dispatch("phx:set-theme") with a single event handler
- Fix buggy reRenderAllCharts that incorrectly used `new MapAPRSMap()` as a
  Map constructor; charts now re-render via themeChanged event directly
This commit is contained in:
Graham McIntire 2026-02-19 09:26:47 -06:00
parent f1b363a2e2
commit aca0571a3e
No known key found for this signature in database
5 changed files with 65 additions and 183 deletions

View file

@ -40,73 +40,6 @@ import { InfoMap } from "./hooks/info_map";
// Import time ago hook // Import time ago hook
import TimeAgoHook from "./hooks/time_ago_hook"; import TimeAgoHook from "./hooks/time_ago_hook";
// Responsive Slideover Hook
let ResponsiveSlideoverHook = {
mounted() {
this.isInitialized = false;
this.handleResize = () => {
const isDesktop = window.innerWidth >= 1024;
const isMobile = window.innerWidth < 1024;
// Set initial state based on screen size
if (!this.isInitialized) {
this.isInitialized = true;
if (isDesktop) {
this.pushEvent("set_slideover_state", { open: true });
} else {
this.pushEvent("set_slideover_state", { open: false });
}
}
};
// Initial check after a brief delay to ensure LiveView is ready
setTimeout(() => {
this.handleResize();
}, 100);
// Listen for resize events with debouncing
let resizeTimer;
this.debouncedResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(this.handleResize, 150);
};
window.addEventListener("resize", this.debouncedResize);
},
destroyed() {
if (this.debouncedResize) {
window.removeEventListener("resize", this.debouncedResize);
}
},
};
// Body Class Hook - Updates body class based on map_page assign
let BodyClassHook = {
mounted() {
this.updateBodyClass();
},
updated() {
this.updateBodyClass();
},
updateBodyClass() {
// Get the map_page value from the element's data attribute
const mapPage = this.el?.dataset?.mapPage === "true";
// Update body class based on map_page value
if (document.body && document.body.classList) {
if (mapPage) {
document.body.classList.add("map-page");
} else {
document.body.classList.remove("map-page");
}
}
},
};
// APRS MapAPRSMap Hook // APRS MapAPRSMap Hook
let Hooks = {}; let Hooks = {};
@ -206,29 +139,10 @@ Object.keys(WeatherChartHooks).forEach(hookName => {
}); });
// Core hooks - no bundle loading needed // Core hooks - no bundle loading needed
Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook;
Hooks.BodyClassHook = BodyClassHook;
Hooks.ErrorBoundary = ErrorBoundary; Hooks.ErrorBoundary = ErrorBoundary;
Hooks.TimeAgoHook = TimeAgoHook; Hooks.TimeAgoHook = TimeAgoHook;
// Helper function to get theme-aware colors // Theme management
const getThemeColors = () => {
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
return {
text: isDark ? "#e5e7eb" : "#111827",
grid: isDark ? "#374151" : "#9ca3af",
background: isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.1)",
};
};
// Theme switching functionality
const theme = (() => {
if (typeof localStorage !== "undefined" && localStorage.getItem("theme")) {
return localStorage.getItem("theme");
}
return "auto";
})();
const applyTheme = (theme) => { const applyTheme = (theme) => {
const element = document.documentElement; const element = document.documentElement;
if (!element) return; if (!element) return;
@ -244,65 +158,29 @@ const applyTheme = (theme) => {
} }
}; };
// Apply initial theme // Apply initial theme from localStorage
applyTheme(theme); applyTheme(localStorage.getItem("theme") || "auto");
window.localStorage.setItem("theme", theme);
// Global function to re-render all charts // Handle theme changes dispatched from LiveView via JS.dispatch
window.reRenderAllCharts = () => { window.addEventListener("phx:set-theme", (e) => {
// Store all chart instances globally so we can access them const theme = e.detail.theme;
if (!window.chartInstances) { applyTheme(theme);
window.chartInstances = new MapAPRSMap(); localStorage.setItem("theme", theme);
}
// Re-render all stored chart instances
window.chartInstances.forEach((chartInstance, elementId) => {
if (chartInstance && typeof chartInstance.renderChart === "function") {
chartInstance.renderChart();
}
});
// Also dispatch a custom event that charts can listen to
window.dispatchEvent(new CustomEvent("themeChanged")); window.dispatchEvent(new CustomEvent("themeChanged"));
};
const handleThemeClick = (selectedTheme) => {
applyTheme(selectedTheme);
localStorage.setItem("theme", selectedTheme);
// Re-render all charts with new theme colors
setTimeout(() => {
window.reRenderAllCharts();
}, 100);
};
// Listen for system theme changes when auto is selected
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (localStorage.getItem("theme") === "auto") {
applyTheme("auto");
// Re-render all charts with new theme colors
setTimeout(() => {
window.reRenderAllCharts();
}, 100);
}
}); });
// Add event listeners for theme switching // Listen for system theme changes when auto is selected
document.addEventListener("DOMContentLoaded", () => { window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
const themeButtons = document.querySelectorAll("[data-set-theme]"); if (localStorage.getItem("theme") === "auto") {
themeButtons.forEach((button) => { applyTheme("auto");
button.addEventListener("click", () => { window.dispatchEvent(new CustomEvent("themeChanged"));
const theme = button.getAttribute("data-set-theme"); }
handleThemeClick(theme);
});
});
}); });
console.log("Creating LiveSocket with hooks:", Object.keys(Hooks)); console.log("Creating LiveSocket with hooks:", Object.keys(Hooks));
let liveSocket = new LiveSocket("/live", Socket, { let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken }, params: { _csrf_token: csrfToken, viewport_width: window.innerWidth },
hooks: Hooks, hooks: Hooks,
timeout: 60000, // 60 second timeout for slow initial loads timeout: 60000, // 60 second timeout for slow initial loads
}); });

View file

@ -475,7 +475,7 @@ defmodule AprsmeWeb.CoreComponents do
</div> </div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<li> <li>
<button data-set-theme="light" data-act-class="ACTIVECLASS" class="flex items-center gap-2"> <button phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "light"})} class="flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path <path
fill-rule="evenodd" fill-rule="evenodd"
@ -487,7 +487,7 @@ defmodule AprsmeWeb.CoreComponents do
</button> </button>
</li> </li>
<li> <li>
<button data-set-theme="dark" data-act-class="ACTIVECLASS" class="flex items-center gap-2"> <button phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "dark"})} class="flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" /> <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg> </svg>
@ -495,7 +495,7 @@ defmodule AprsmeWeb.CoreComponents do
</button> </button>
</li> </li>
<li> <li>
<button data-set-theme="auto" data-act-class="ACTIVECLASS" class="flex items-center gap-2"> <button phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "auto"})} class="flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path <path
fill-rule="evenodd" fill-rule="evenodd"

View file

@ -4,13 +4,7 @@ defmodule AprsmeWeb.Layouts do
embed_templates "layouts/*" embed_templates "layouts/*"
def body_class(assigns) do def body_class(_assigns) do
base_classes = ["bg-base-100 antialiased"] ["bg-base-100 antialiased"]
if Map.get(assigns, :map_page, false) do
base_classes ++ ["map-page"]
else
base_classes
end
end end
end end

View file

@ -8,9 +8,9 @@
} }
/* Reset constraints for non-map pages */ /* Reset constraints for non-map pages */
body:not(.map-page) main, body:not(:has(#main-content[data-map-page="true"])) main,
body:not(.map-page) .phx-main, body:not(:has(#main-content[data-map-page="true"])) .phx-main,
body:not(.map-page) div[data-phx-main="true"] { body:not(:has(#main-content[data-map-page="true"])) div[data-phx-main="true"] {
overflow: visible !important; overflow: visible !important;
height: auto !important; height: auto !important;
max-height: none !important; max-height: none !important;
@ -18,48 +18,47 @@
} }
/* Ensure proper scrolling for non-map pages */ /* Ensure proper scrolling for non-map pages */
body:not(.map-page) { body:not(:has(#main-content[data-map-page="true"])) {
overflow-y: auto !important; overflow-y: auto !important;
height: auto !important; height: auto !important;
} }
/* Only apply map constraints to map pages */ /* Only apply map constraints to map pages */
body.map-page, body:has(#main-content[data-map-page="true"]) {
body.map-page html {
overflow: hidden !important; overflow: hidden !important;
height: 100vh !important; height: 100vh !important;
} }
/* Map page specific styles */ /* Map page specific styles - hide navbar header */
body.map-page header { body:has(#main-content[data-map-page="true"]) .navbar {
display: none; display: none;
} }
body.map-page main { body:has(#main-content[data-map-page="true"]) main {
padding: 0; padding: 0;
max-width: none; max-width: none;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
} }
body.map-page main > div { body:has(#main-content[data-map-page="true"]) main > div {
max-width: none; max-width: none;
height: 100%; height: 100%;
} }
body.map-page .phx-main { body:has(#main-content[data-map-page="true"]) .phx-main {
position: relative; position: relative;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
} }
body.map-page div[data-phx-main="true"] { body:has(#main-content[data-map-page="true"]) div[data-phx-main="true"] {
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
} }
/* Full page map for APRS home page and callsign pages */ /* Full page map for APRS home page and callsign pages */
body.map-page #aprs-map { body:has(#main-content[data-map-page="true"]) #aprs-map {
position: fixed !important; position: fixed !important;
top: 0 !important; top: 0 !important;
left: 0 !important; left: 0 !important;
@ -72,24 +71,24 @@
/* Desktop slideover behavior for map */ /* Desktop slideover behavior for map */
@media (min-width: 1024px) { @media (min-width: 1024px) {
body.map-page #aprs-map.slideover-open { body:has(#main-content[data-map-page="true"]) #aprs-map.slideover-open {
right: 352px !important; right: 352px !important;
} }
body.map-page #aprs-map.slideover-closed { body:has(#main-content[data-map-page="true"]) #aprs-map.slideover-closed {
right: 0 !important; right: 0 !important;
} }
} }
/* Mobile slideover behavior for map */ /* Mobile slideover behavior for map */
@media (max-width: 1023px) { @media (max-width: 1023px) {
body.map-page #aprs-map { body:has(#main-content[data-map-page="true"]) #aprs-map {
right: 0 !important; right: 0 !important;
} }
} }
/* Slideover panel responsive styles */ /* Slideover panel responsive styles */
body.map-page .slideover-panel { body:has(#main-content[data-map-page="true"]) .slideover-panel {
position: fixed; position: fixed;
top: 0; top: 0;
right: 0; right: 0;
@ -104,35 +103,35 @@
/* Desktop slideover behavior */ /* Desktop slideover behavior */
@media (min-width: 1024px) { @media (min-width: 1024px) {
body.map-page .slideover-panel.slideover-open { body:has(#main-content[data-map-page="true"]) .slideover-panel.slideover-open {
transform: translateX(0); transform: translateX(0);
} }
body.map-page .slideover-panel.slideover-closed { body:has(#main-content[data-map-page="true"]) .slideover-panel.slideover-closed {
transform: translateX(100%); transform: translateX(100%);
} }
} }
/* Mobile slideover behavior */ /* Mobile slideover behavior */
@media (max-width: 1023px) { @media (max-width: 1023px) {
body.map-page .slideover-panel { body:has(#main-content[data-map-page="true"]) .slideover-panel {
width: 100vw; width: 100vw;
max-width: 400px; max-width: 400px;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.2); box-shadow: -8px 0 32px rgba(0, 0, 0, 0.2);
} }
body.map-page .slideover-panel.slideover-open { body:has(#main-content[data-map-page="true"]) .slideover-panel.slideover-open {
transform: translateX(0); transform: translateX(0);
} }
body.map-page .slideover-panel.slideover-closed { body:has(#main-content[data-map-page="true"]) .slideover-panel.slideover-closed {
transform: translateX(100%); transform: translateX(100%);
} }
} }
/* Mobile panel improvements */ /* Mobile panel improvements */
@media (max-width: 1023px) { @media (max-width: 1023px) {
body.map-page .slideover-panel { body:has(#main-content[data-map-page="true"]) .slideover-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -140,12 +139,12 @@
/* Improve scrolling on mobile */ /* Improve scrolling on mobile */
@media (max-width: 1023px) { @media (max-width: 1023px) {
body.map-page .slideover-panel { body:has(#main-content[data-map-page="true"]) .slideover-panel {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
body.map-page .slideover-panel .flex-1 { body:has(#main-content[data-map-page="true"]) .slideover-panel .flex-1 {
min-height: 0; min-height: 0;
} }
} }
@ -155,7 +154,6 @@
<main <main
id="main-content" id="main-content"
class={if assigns[:map_page], do: "min-h-screen bg-base-100", else: "bg-base-100"} class={if assigns[:map_page], do: "min-h-screen bg-base-100", else: "bg-base-100"}
phx-hook="BodyClassHook"
data-map-page={to_string(assigns[:map_page] || false)} data-map-page={to_string(assigns[:map_page] || false)}
> >
<div> <div>

View file

@ -7,7 +7,9 @@ defmodule AprsmeWeb.MapLive.Index do
import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1] import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1]
import AprsmeWeb.MapLive.Components import AprsmeWeb.MapLive.Components
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1] import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_patch: 2, put_flash: 3]
import Phoenix.LiveView,
only: [connected?: 1, get_connect_params: 1, push_event: 3, push_patch: 2, put_flash: 3]
# Import the new components module # Import the new components module
alias Aprsme.Packets alias Aprsme.Packets
@ -198,6 +200,9 @@ defmodule AprsmeWeb.MapLive.Index do
# Start packet batcher for efficient updates # Start packet batcher for efficient updates
{:ok, batcher_pid} = PacketBatcher.start_link(self()) {:ok, batcher_pid} = PacketBatcher.start_link(self())
# Determine initial slideover state from client viewport width
slideover_open = initial_slideover_open?(socket)
assign(socket, assign(socket,
map_ready: false, map_ready: false,
map_bounds: initial_bounds, map_bounds: initial_bounds,
@ -212,7 +217,7 @@ defmodule AprsmeWeb.MapLive.Index do
trail_duration: trail_duration, trail_duration: trail_duration,
historical_hours: historical_hours, historical_hours: historical_hours,
packet_age_threshold: packet_age_threshold, packet_age_threshold: packet_age_threshold,
slideover_open: true, slideover_open: slideover_open,
deployed_at: deployed_at, deployed_at: deployed_at,
map_page: true, map_page: true,
packet_buffer: [], packet_buffer: [],
@ -239,6 +244,15 @@ defmodule AprsmeWeb.MapLive.Index do
if connection_disabled, do: "disconnected", else: "connected" if connection_disabled, do: "disconnected", else: "connected"
end end
defp initial_slideover_open?(socket) do
if connected?(socket) do
viewport_width = get_connect_params(socket)["viewport_width"] || 1024
viewport_width >= 1024
else
true
end
end
@spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t() @spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t()
defp assign_defaults(socket, one_hour_ago) do defp assign_defaults(socket, one_hour_ago) do
assign(socket, assign(socket,
@ -269,7 +283,6 @@ defmodule AprsmeWeb.MapLive.Index do
pending_batch_tasks: [], pending_batch_tasks: [],
# Overlay controls # Overlay controls
overlay_callsign: "", overlay_callsign: "",
# Slideover state - will be set based on screen size
slideover_open: true, slideover_open: true,
# Track when last update occurred for real-time display in map sidebar # Track when last update occurred for real-time display in map sidebar
# Updated when packets are processed or map bounds change # Updated when packets are processed or map bounds change
@ -1431,7 +1444,6 @@ defmodule AprsmeWeb.MapLive.Index do
"slideover-panel", "slideover-panel",
if(@slideover_open, do: "slideover-open", else: "slideover-closed") if(@slideover_open, do: "slideover-open", else: "slideover-closed")
]} ]}
phx-hook="ResponsiveSlideoverHook"
> >
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-slate-200 dark:border-slate-700 bg-gradient-to-r from-indigo-600 to-purple-600 text-white"> <div class="flex items-center justify-between p-6 border-b border-slate-200 dark:border-slate-700 bg-gradient-to-r from-indigo-600 to-purple-600 text-white">