/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.
206 lines
6.6 KiB
Elixir
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
|