prop/lib/microwaveprop_web/components/layouts.ex
Graham McIntire cf6812f154
fix(contact): stop loading-flash on already-enriched contacts
Two related fixes:

- Drop the global contact_enrichment:{hrrr,weather,solar} subscriptions.
  These topics fire for every QSO's enrichment completion, and the
  handlers blindly re-queried data for the currently-displayed contact,
  causing spurious re-renders whenever any enrichment worker finished
  anywhere in the system. The per-contact terrain subscription stays —
  that one is correctly scoped.

- Only add a slot to `hydration_pending` when the contact's status
  column says the work is genuinely in flight (:queued or :processing).
  Previously every connected mount flashed spinners over all eight
  enrichment sections, even for contacts whose data was already cached
  and about to return instantly from the async task.

Also drop the Donate-to-NTMS footer link.
2026-04-18 13:56:01 -05:00

220 lines
7.2 KiB
Elixir

defmodule MicrowavepropWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
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.
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
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">NTMS Propagation Prediction</a>
<nav class="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="/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 navigate="/users">Users</.link></li>
<li><.link navigate="/admin/contact-edits">Contact edits</.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-3">
<.deploy_stamp />
<.theme_toggle />
</div>
</header>
<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>
</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"
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={gettext("We can't find the internet")}
hidden
>
{gettext("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={gettext("Something went wrong!")}
hidden
>
{gettext("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.
"""
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
class="hidden md:inline text-xs opacity-60"
title={"Last deployed #{@iso}"}
>
Deployed {@ago}
</span>
"""
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.
"""
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