feat: responsive mobile nav with hamburger menu
Some checks failed
Build and Push / Build and Push Docker Image (push) Has been cancelled

This commit is contained in:
Graham McIntire 2026-08-02 12:43:13 -05:00
parent ffbaa6230f
commit e8ff087807
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -40,8 +40,8 @@ defmodule MicrowavepropWeb.Layouts do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8 border-b border-base-200">
<div class="flex-1 gap-4">
<a href="/" class="font-semibold tracking-tight">NTMS Microwave Propagation</a>
<nav class="flex gap-1">
<a href="/" class="font-semibold tracking-tight shrink-0">NTMS Microwave Propagation</a>
<nav class="hidden lg:flex gap-1">
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
<.link navigate="/eme" class="btn btn-ghost btn-sm">EME</.link>
@ -98,11 +98,191 @@ defmodule MicrowavepropWeb.Layouts do
<% end %>
</nav>
</div>
<div class="flex-none flex items-center gap-3">
<div class="flex-none flex items-center gap-2">
<button
type="button"
id="mobile-menu-btn"
class="lg:hidden btn btn-ghost btn-sm"
aria-label="Toggle menu"
aria-expanded="false"
>
<span id="menu-icon-bars"><.icon name="hero-bars-3" class="size-5" /></span>
<span id="menu-icon-x" class="hidden"><.icon name="hero-x-mark" class="size-5" /></span>
</button>
<.theme_toggle />
</div>
</header>
<div
id="mobile-menu"
phx-hook=".MobileMenu"
class="lg:hidden overflow-hidden transition-all duration-300 ease-in-out max-h-0 opacity-0 bg-base-100 border-b border-base-200 shadow-lg"
>
<nav class="px-4 py-3 space-y-0.5">
<.link
navigate="/map"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Map</.link>
<.link
navigate="/path"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Path</.link>
<.link
navigate="/eme"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>EME</.link>
<div class="pt-2">
<div class="px-3 py-1 text-xs font-semibold uppercase tracking-wider text-base-content/40">
Rover
</div>
<.link
navigate="/rover"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Rover Planner</.link>
<.link
navigate="/rover-locations"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Rover Locations</.link>
<.link
navigate="/rover-planning"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Rover Planning</.link>
</div>
<.link
navigate="/beacons"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Beacons</.link>
<.link
navigate="/submit"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Submit</.link>
<.link
navigate="/contacts"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Contacts</.link>
<.link
navigate="/contacts/map"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Contact Map</.link>
<.link
navigate="/about"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>About</.link>
<div :if={@current_scope && @current_scope.user && @current_scope.user.is_admin} class="pt-2">
<div class="px-3 py-1 text-xs font-semibold uppercase tracking-wider text-base-content/40">
Admin
</div>
<.link
navigate="/users"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Users</.link>
<.link
navigate="/admin/beacon-monitors"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Beacon monitors</.link>
<.link
navigate="/admin/contact-edits"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Contact edits</.link>
<.link
navigate="/status"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Status</.link>
<.link
href="/admin/oban"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Oban</.link>
</div>
<div class="border-t border-base-200 my-2"></div>
<%= if @current_scope && @current_scope.user do %>
<.link
navigate={~p"/u/#{@current_scope.user.callsign}"}
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>{@current_scope.user.callsign}</.link>
<.link
href={~p"/users/settings"}
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Settings</.link>
<.link
href={~p"/users/log-out"}
method="delete"
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Log out</.link>
<% else %>
<.link
href={~p"/users/register"}
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Register</.link>
<.link
href={~p"/users/log-in"}
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
>Log in</.link>
<% end %>
</nav>
</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".MobileMenu">
export default {
mounted() {
this.panel = this.el;
this.btn = document.getElementById("mobile-menu-btn");
this.barsIcon = document.getElementById("menu-icon-bars");
this.xIcon = document.getElementById("menu-icon-x");
this.open = false;
this.onBtnClick = () => this.toggle();
this.btn.addEventListener("click", this.onBtnClick);
this.onOutsideClick = (e) => {
if (!this.open) return;
if (!this.panel.contains(e.target) && !this.btn.contains(e.target)) {
this.close();
}
};
document.addEventListener("click", this.onOutsideClick);
this.onLinkClick = () => this.close();
this.panel.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", this.onLinkClick);
});
},
destroyed() {
if (this.btn) {
this.btn.removeEventListener("click", this.onBtnClick);
}
document.removeEventListener("click", this.onOutsideClick);
if (this.panel) {
this.panel.querySelectorAll("a").forEach((link) => {
link.removeEventListener("click", this.onLinkClick);
});
}
},
toggle() {
this.open ? this.close() : this.open_();
},
open_() {
this.open = true;
this.panel.classList.remove("max-h-0", "opacity-0");
this.panel.classList.add("max-h-[40rem]", "opacity-100");
if (this.barsIcon) this.barsIcon.classList.add("hidden");
if (this.xIcon) this.xIcon.classList.remove("hidden");
this.btn.setAttribute("aria-expanded", "true");
},
close() {
this.open = false;
this.panel.classList.add("max-h-0", "opacity-0");
this.panel.classList.remove("max-h-[40rem]", "opacity-100");
if (this.barsIcon) this.barsIcon.classList.remove("hidden");
if (this.xIcon) this.xIcon.classList.add("hidden");
this.btn.setAttribute("aria-expanded", "false");
},
};
</script>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class={["mx-auto space-y-4", @max_width]}>
{render_slot(@inner_block)}