prop/lib/microwaveprop_web/live/beacon_live/index.ex
Graham McIntire 810f5a22ce
Beacon list map, profile page polish, nav ordering fix
/beacons gains a Leaflet map above the table showing every approved
beacon as a circle marker with a popup that links to the detail page.
New BeaconsListMap JS hook (assets/js/beacons_list_map_hook.ts) walks a
data-beacons JSON payload, fitBounds over the marker set, and mirrors
the format_freq/1 integer-comma formatting used on the detail view so
the popups match the rest of the UI. Approved/on-air beacons render
green, off-air beacons render gray. BeaconLive.Index.mount/3 now
materializes the list into an assign so it can encode it into the data
attribute; the live PubSub path keeps the map in sync on
create/update/delete.

Profile (/u/:callsign) polish: drop the hero-radio, hero-signal, and
hero-information-circle icons per request, and replace the daisyUI
`avatar placeholder` wrapper with a plain flex-centered circle so the
initial letter actually lives inside the circle instead of anchored to
the top-left corner (daisyUI 5 removed the old placeholder-centering
behavior).

Navigation ordering: in the top navbar (layouts.ex) Contacts now sits
immediately before Contact Map instead of after it. All three vertical
sidebars already had them adjacent in the correct order, so only the
horizontal nav changed.
2026-04-12 16:23:09 -05:00

206 lines
6.6 KiB
Elixir

defmodule MicrowavepropWeb.BeaconLive.Index do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
Beacons
<:subtitle>Microwave beacons tracked by NTMS.</:subtitle>
<:actions>
<.button variant="primary" navigate={~p"/beacons/new"}>
<.icon name="hero-plus" /> Submit Beacon
</.button>
</:actions>
</.header>
<div
id="beacons-map"
phx-hook="BeaconsListMap"
phx-update="ignore"
data-beacons={@beacons_json}
class="h-80 w-full rounded-lg border border-base-300 mb-6 z-0"
>
</div>
<.table
id="beacons"
rows={@streams.beacons}
row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end}
>
<:col :let={{_id, beacon}} label="Frequency (MHz)">
{Beacon.format_freq(beacon.frequency_mhz)}
</:col>
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)}</:col>
<:col :let={{_id, beacon}} label="On air">
<span class={["badge badge-sm", (beacon.on_the_air && "badge-success") || "badge-ghost"]}>
{if beacon.on_the_air, do: "Yes", else: "No"}
</span>
</:col>
<:action :let={{_id, beacon}}>
<div class="sr-only">
<.link navigate={~p"/beacons/#{beacon}"}>Show</.link>
</div>
<.link :if={admin?(@current_scope)} navigate={~p"/beacons/#{beacon}/edit"}>Edit</.link>
</:action>
<:action :let={{id, beacon}}>
<.link
:if={admin?(@current_scope)}
phx-click={JS.push("delete", value: %{id: beacon.id}) |> hide("##{id}")}
data-confirm="Delete this beacon?"
>
Delete
</.link>
</:action>
</.table>
<div :if={admin?(@current_scope) and @pending != []} class="mt-10">
<.header>
Pending approval
<:subtitle>Submitted beacons waiting for admin review.</:subtitle>
</.header>
<.table
id="pending-beacons"
rows={@streams.pending}
row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end}
>
<:col :let={{_id, beacon}} label="Frequency (MHz)">
{Beacon.format_freq(beacon.frequency_mhz)}
</:col>
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Submitted">
{Calendar.strftime(beacon.inserted_at, "%Y-%m-%d %H:%M UTC")}
</:col>
<:action :let={{_id, beacon}}>
<.link
phx-click={JS.push("approve", value: %{id: beacon.id})}
data-confirm="Approve this beacon?"
>
Approve
</.link>
</:action>
<:action :let={{id, beacon}}>
<.link
phx-click={JS.push("delete", value: %{id: beacon.id}) |> hide("##{id}")}
data-confirm="Delete this beacon?"
>
Delete
</.link>
</:action>
</.table>
</div>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: Beacons.subscribe_beacons()
beacons = Beacons.list_beacons()
pending =
if admin?(socket.assigns.current_scope) do
Beacons.list_pending_beacons()
else
[]
end
{:ok,
socket
|> assign(:page_title, "Beacons")
|> assign(:pending, pending)
|> assign(:beacons_json, encode_beacons(beacons))
|> stream(:beacons, beacons)
|> stream(:pending, pending)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
beacon = Beacons.get_beacon!(id)
{:ok, _} = Beacons.delete_beacon(beacon)
{:noreply,
socket
|> stream_delete(:beacons, beacon)
|> stream_delete(:pending, beacon)}
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
end
def handle_event("approve", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
beacon = Beacons.get_beacon!(id)
{:ok, approved} = Beacons.approve_beacon(beacon)
{:noreply,
socket
|> put_flash(:info, "Approved #{approved.callsign}.")
|> stream_delete(:pending, beacon)
|> stream_insert(:beacons, approved)}
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
end
@impl true
def handle_info({type, %Beacon{}}, socket) when type in [:created, :updated, :deleted] do
beacons = Beacons.list_beacons()
pending =
if admin?(socket.assigns.current_scope) do
Beacons.list_pending_beacons()
else
[]
end
{:noreply,
socket
|> assign(:pending, pending)
|> assign(:beacons_json, encode_beacons(beacons))
|> stream(:beacons, beacons, reset: true)
|> stream(:pending, pending, reset: true)}
end
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
# Serialize just the fields the map hook needs. `phx-update="ignore"`
# means this data attribute is only read on initial hook mount — the
# map won't re-render when beacons change without a full LV reload.
# For now that's fine because list churn is rare; upgrade to
# push_event("update_beacons", ...) if we start getting live PubSub
# updates that need to reflect on the map without a refresh.
defp encode_beacons(beacons) do
beacons
|> Enum.filter(&(is_number(&1.lat) and is_number(&1.lon)))
|> Enum.map(fn b ->
%{
id: b.id,
callsign: b.callsign,
lat: b.lat,
lon: b.lon,
frequency_mhz: b.frequency_mhz,
grid: b.grid,
keying: Beacon.keying_label(b.keying),
on_the_air: b.on_the_air
}
end)
|> Jason.encode!()
end
end