Some checks failed
Build and Push / Build and Push Docker Image (push) Has been cancelled
423 lines
16 KiB
Elixir
423 lines
16 KiB
Elixir
defmodule MicrowavepropWeb.Layouts do
|
|
@moduledoc """
|
|
Root layout, flash-group, and app layout used by every page.
|
|
"""
|
|
use MicrowavepropWeb, :html
|
|
|
|
# Embed all files in layouts/* within this module.
|
|
# The default root.html.heex file contains the HTML
|
|
# skeleton of your application, namely HTML headers
|
|
# and other static content.
|
|
alias Phoenix.LiveView.Rendered
|
|
|
|
embed_templates "layouts/*"
|
|
|
|
@doc """
|
|
Renders your app layout.
|
|
|
|
This function is typically invoked from every template,
|
|
and it often contains your application menu, sidebar,
|
|
or similar.
|
|
|
|
## Examples
|
|
|
|
<Layouts.app flash={@flash}>
|
|
<h1>Content</h1>
|
|
</Layouts.app>
|
|
|
|
"""
|
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
|
|
|
attr :current_scope, :map,
|
|
default: nil,
|
|
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
|
|
|
attr :max_width, :string, default: "max-w-2xl", doc: "max width class for the content container"
|
|
slot :inner_block, required: true
|
|
|
|
@spec app(assigns :: map()) :: Rendered.t()
|
|
def app(assigns) 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 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>
|
|
<div class="dropdown">
|
|
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
|
|
Rover <.icon name="hero-chevron-down-micro" class="size-3" />
|
|
</div>
|
|
<ul
|
|
tabindex="0"
|
|
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-48 p-2 shadow-lg"
|
|
>
|
|
<li><.link navigate="/rover">Rover Planner</.link></li>
|
|
<li><.link navigate="/rover-locations">Rover Locations</.link></li>
|
|
<li><.link navigate="/rover-planning">Rover Planning</.link></li>
|
|
</ul>
|
|
</div>
|
|
<.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons</.link>
|
|
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
|
|
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
|
|
<.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map</.link>
|
|
<.link navigate="/about" class="btn btn-ghost btn-sm">About</.link>
|
|
<div
|
|
:if={@current_scope && @current_scope.user && @current_scope.user.is_admin}
|
|
class="dropdown dropdown-end"
|
|
>
|
|
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
|
|
Admin <.icon name="hero-chevron-down-micro" class="size-3" />
|
|
</div>
|
|
<ul
|
|
tabindex="0"
|
|
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-44 p-2 shadow-lg"
|
|
>
|
|
<li><.link href="/admin/dashboard">Dashboard</.link></li>
|
|
<li><.link navigate="/users">Users</.link></li>
|
|
<li><.link navigate="/admin/beacon-monitors">Beacon monitors</.link></li>
|
|
<li><.link navigate="/admin/contact-edits">Contact edits</.link></li>
|
|
<li><.link navigate="/status">Status</.link></li>
|
|
<li><.link href="/admin/oban">Oban</.link></li>
|
|
</ul>
|
|
</div>
|
|
<%= if @current_scope && @current_scope.user do %>
|
|
<.link
|
|
navigate={~p"/u/#{@current_scope.user.callsign}"}
|
|
class="btn btn-ghost btn-sm"
|
|
>
|
|
{@current_scope.user.callsign}
|
|
</.link>
|
|
<.link href={~p"/users/settings"} class="btn btn-ghost btn-sm">Settings</.link>
|
|
<.link href={~p"/users/log-out"} method="delete" class="btn btn-ghost btn-sm">
|
|
Log out
|
|
</.link>
|
|
<% else %>
|
|
<.link href={~p"/users/register"} class="btn btn-ghost btn-sm">Register</.link>
|
|
<.link href={~p"/users/log-in"} class="btn btn-ghost btn-sm">Log in</.link>
|
|
<% end %>
|
|
</nav>
|
|
</div>
|
|
<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
|
|
href="/admin/dashboard"
|
|
class="block px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors text-sm font-medium"
|
|
>Dashboard</.link>
|
|
<.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)}
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="px-4 sm:px-6 lg:px-8 pb-6">
|
|
<div class={["mx-auto text-center text-xs opacity-60 space-y-1", @max_width]}>
|
|
<div>
|
|
Built by and for the
|
|
<a href="https://ntms.org" target="_blank" class="link link-hover">
|
|
North Texas Microwave Society
|
|
</a>
|
|
</div>
|
|
<div>
|
|
<.link navigate="/privacy" class="link link-hover">Privacy</.link>
|
|
</div>
|
|
<div>
|
|
<.deploy_stamp />
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
|
|
<.flash_group flash={@flash} />
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Shows the flash group with standard titles and content.
|
|
|
|
## Examples
|
|
|
|
<.flash_group flash={@flash} />
|
|
"""
|
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
|
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
|
|
|
@spec flash_group(assigns :: map()) :: Rendered.t()
|
|
def flash_group(assigns) do
|
|
~H"""
|
|
<div id={@id} aria-live="polite">
|
|
<.flash kind={:info} flash={@flash} />
|
|
<.flash kind={:error} flash={@flash} />
|
|
|
|
<.flash id="client-error" kind={:error} title="Unable to reach the server" hidden>
|
|
Attempting to reconnect
|
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
|
</.flash>
|
|
|
|
<.flash id="server-error" kind={:error} title="Something went wrong!" hidden>
|
|
Attempting to reconnect
|
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
|
</.flash>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Shows when the running release was deployed, as a compact relative timestamp
|
|
with the full UTC time in the tooltip.
|
|
"""
|
|
@spec deploy_stamp(assigns :: map()) :: Rendered.t()
|
|
def deploy_stamp(assigns) do
|
|
ts = Microwaveprop.Application.build_timestamp()
|
|
iso = Calendar.strftime(ts, "%Y-%m-%d %H:%M UTC")
|
|
assigns = assign(assigns, build_time: ts, iso: iso, ago: format_time_ago(ts))
|
|
|
|
~H"""
|
|
<span title={"Last deployed #{@iso}"}>
|
|
Deployed {@ago} · <span class="font-mono">{@iso}</span>
|
|
</span>
|
|
"""
|
|
end
|
|
|
|
@spec page_title(map()) :: String.t()
|
|
def page_title(assigns) do
|
|
case assigns[:page_title] do
|
|
nil -> "NTMS Microwave Propagation"
|
|
title -> title <> " · NTMS Microwave Propagation"
|
|
end
|
|
end
|
|
|
|
defp format_time_ago(%DateTime{} = dt) do
|
|
diff = max(DateTime.diff(DateTime.utc_now(), dt, :second), 0)
|
|
|
|
cond do
|
|
diff < 60 -> "just now"
|
|
diff < 3600 -> "#{div(diff, 60)}m ago"
|
|
diff < 86_400 -> "#{div(diff, 3600)}h ago"
|
|
diff < 2_592_000 -> "#{div(diff, 86_400)}d ago"
|
|
diff < 31_536_000 -> "#{div(diff, 2_592_000)}mo ago"
|
|
true -> "#{div(diff, 31_536_000)}y ago"
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Provides dark vs light theme toggle based on themes defined in app.css.
|
|
|
|
See <head> in root.html.heex which applies the theme before page load.
|
|
"""
|
|
@spec theme_toggle(assigns :: map()) :: Rendered.t()
|
|
def theme_toggle(assigns) do
|
|
~H"""
|
|
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
|
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
|
|
|
|
<button
|
|
class="flex p-2 cursor-pointer w-1/3"
|
|
phx-click={JS.dispatch("phx:set-theme")}
|
|
data-phx-theme="system"
|
|
>
|
|
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
|
|
</button>
|
|
|
|
<button
|
|
class="flex p-2 cursor-pointer w-1/3"
|
|
phx-click={JS.dispatch("phx:set-theme")}
|
|
data-phx-theme="light"
|
|
>
|
|
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
|
|
</button>
|
|
|
|
<button
|
|
class="flex p-2 cursor-pointer w-1/3"
|
|
phx-click={JS.dispatch("phx:set-theme")}
|
|
data-phx-theme="dark"
|
|
>
|
|
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
|
|
</button>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|