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 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
let Hooks = {};
@ -206,29 +139,10 @@ Object.keys(WeatherChartHooks).forEach(hookName => {
});
// Core hooks - no bundle loading needed
Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook;
Hooks.BodyClassHook = BodyClassHook;
Hooks.ErrorBoundary = ErrorBoundary;
Hooks.TimeAgoHook = TimeAgoHook;
// Helper function to get theme-aware colors
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";
})();
// Theme management
const applyTheme = (theme) => {
const element = document.documentElement;
if (!element) return;
@ -244,65 +158,29 @@ const applyTheme = (theme) => {
}
};
// Apply initial theme
applyTheme(theme);
window.localStorage.setItem("theme", theme);
// Apply initial theme from localStorage
applyTheme(localStorage.getItem("theme") || "auto");
// Global function to re-render all charts
window.reRenderAllCharts = () => {
// Store all chart instances globally so we can access them
if (!window.chartInstances) {
window.chartInstances = new MapAPRSMap();
}
// 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
// Handle theme changes dispatched from LiveView via JS.dispatch
window.addEventListener("phx:set-theme", (e) => {
const theme = e.detail.theme;
applyTheme(theme);
localStorage.setItem("theme", theme);
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
document.addEventListener("DOMContentLoaded", () => {
const themeButtons = document.querySelectorAll("[data-set-theme]");
themeButtons.forEach((button) => {
button.addEventListener("click", () => {
const theme = button.getAttribute("data-set-theme");
handleThemeClick(theme);
});
});
// 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"));
}
});
console.log("Creating LiveSocket with hooks:", Object.keys(Hooks));
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
params: { _csrf_token: csrfToken, viewport_width: window.innerWidth },
hooks: Hooks,
timeout: 60000, // 60 second timeout for slow initial loads
});

View file

@ -475,7 +475,7 @@ defmodule AprsmeWeb.CoreComponents do
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<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">
<path
fill-rule="evenodd"
@ -487,7 +487,7 @@ defmodule AprsmeWeb.CoreComponents do
</button>
</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">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
@ -495,7 +495,7 @@ defmodule AprsmeWeb.CoreComponents do
</button>
</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">
<path
fill-rule="evenodd"

View file

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

View file

@ -8,9 +8,9 @@
}
/* Reset constraints for non-map pages */
body:not(.map-page) main,
body:not(.map-page) .phx-main,
body:not(.map-page) div[data-phx-main="true"] {
body:not(:has(#main-content[data-map-page="true"])) main,
body:not(:has(#main-content[data-map-page="true"])) .phx-main,
body:not(:has(#main-content[data-map-page="true"])) div[data-phx-main="true"] {
overflow: visible !important;
height: auto !important;
max-height: none !important;
@ -18,48 +18,47 @@
}
/* Ensure proper scrolling for non-map pages */
body:not(.map-page) {
body:not(:has(#main-content[data-map-page="true"])) {
overflow-y: auto !important;
height: auto !important;
}
/* Only apply map constraints to map pages */
body.map-page,
body.map-page html {
body:has(#main-content[data-map-page="true"]) {
overflow: hidden !important;
height: 100vh !important;
}
/* Map page specific styles */
body.map-page header {
/* Map page specific styles - hide navbar header */
body:has(#main-content[data-map-page="true"]) .navbar {
display: none;
}
body.map-page main {
body:has(#main-content[data-map-page="true"]) main {
padding: 0;
max-width: none;
height: 100vh;
overflow: hidden;
}
body.map-page main > div {
body:has(#main-content[data-map-page="true"]) main > div {
max-width: none;
height: 100%;
}
body.map-page .phx-main {
body:has(#main-content[data-map-page="true"]) .phx-main {
position: relative;
height: 100vh;
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;
overflow: hidden;
}
/* 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;
top: 0 !important;
left: 0 !important;
@ -72,24 +71,24 @@
/* Desktop slideover behavior for map */
@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;
}
body.map-page #aprs-map.slideover-closed {
body:has(#main-content[data-map-page="true"]) #aprs-map.slideover-closed {
right: 0 !important;
}
}
/* Mobile slideover behavior for map */
@media (max-width: 1023px) {
body.map-page #aprs-map {
body:has(#main-content[data-map-page="true"]) #aprs-map {
right: 0 !important;
}
}
/* Slideover panel responsive styles */
body.map-page .slideover-panel {
body:has(#main-content[data-map-page="true"]) .slideover-panel {
position: fixed;
top: 0;
right: 0;
@ -104,35 +103,35 @@
/* Desktop slideover behavior */
@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);
}
body.map-page .slideover-panel.slideover-closed {
body:has(#main-content[data-map-page="true"]) .slideover-panel.slideover-closed {
transform: translateX(100%);
}
}
/* Mobile slideover behavior */
@media (max-width: 1023px) {
body.map-page .slideover-panel {
body:has(#main-content[data-map-page="true"]) .slideover-panel {
width: 100vw;
max-width: 400px;
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);
}
body.map-page .slideover-panel.slideover-closed {
body:has(#main-content[data-map-page="true"]) .slideover-panel.slideover-closed {
transform: translateX(100%);
}
}
/* Mobile panel improvements */
@media (max-width: 1023px) {
body.map-page .slideover-panel {
body:has(#main-content[data-map-page="true"]) .slideover-panel {
display: flex;
flex-direction: column;
}
@ -140,12 +139,12 @@
/* Improve scrolling on mobile */
@media (max-width: 1023px) {
body.map-page .slideover-panel {
body:has(#main-content[data-map-page="true"]) .slideover-panel {
-webkit-overflow-scrolling: touch;
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;
}
}
@ -155,7 +154,6 @@
<main
id="main-content"
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)}
>
<div>

View file

@ -7,7 +7,9 @@ defmodule AprsmeWeb.MapLive.Index do
import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1]
import AprsmeWeb.MapLive.Components
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
alias Aprsme.Packets
@ -198,6 +200,9 @@ defmodule AprsmeWeb.MapLive.Index do
# Start packet batcher for efficient updates
{:ok, batcher_pid} = PacketBatcher.start_link(self())
# Determine initial slideover state from client viewport width
slideover_open = initial_slideover_open?(socket)
assign(socket,
map_ready: false,
map_bounds: initial_bounds,
@ -212,7 +217,7 @@ defmodule AprsmeWeb.MapLive.Index do
trail_duration: trail_duration,
historical_hours: historical_hours,
packet_age_threshold: packet_age_threshold,
slideover_open: true,
slideover_open: slideover_open,
deployed_at: deployed_at,
map_page: true,
packet_buffer: [],
@ -239,6 +244,15 @@ defmodule AprsmeWeb.MapLive.Index do
if connection_disabled, do: "disconnected", else: "connected"
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()
defp assign_defaults(socket, one_hour_ago) do
assign(socket,
@ -269,7 +283,6 @@ defmodule AprsmeWeb.MapLive.Index do
pending_batch_tasks: [],
# Overlay controls
overlay_callsign: "",
# Slideover state - will be set based on screen size
slideover_open: true,
# Track when last update occurred for real-time display in map sidebar
# Updated when packets are processed or map bounds change
@ -1431,7 +1444,6 @@ defmodule AprsmeWeb.MapLive.Index do
"slideover-panel",
if(@slideover_open, do: "slideover-open", else: "slideover-closed")
]}
phx-hook="ResponsiveSlideoverHook"
>
<!-- 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">