diff --git a/lib/microwaveprop/beacons.ex b/lib/microwaveprop/beacons.ex index 91cfdbb9..99d1f615 100644 --- a/lib/microwaveprop/beacons.ex +++ b/lib/microwaveprop/beacons.ex @@ -39,6 +39,15 @@ defmodule Microwaveprop.Beacons do ) end + @doc """ + Base Ecto query for approved beacons. Used as a `live_table` + `data_provider` so sort/search/pagination can be applied on top. + """ + @spec approved_beacons_query() :: Ecto.Query.t() + def approved_beacons_query do + from b in Beacon, as: :resource, where: b.approved == true + end + @doc "Returns unapproved beacons awaiting admin review." @spec list_pending_beacons() :: [Beacon.t()] def list_pending_beacons do diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex index bfe67450..2130b087 100644 --- a/lib/microwaveprop_web/live/beacon_live/index.ex +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -2,9 +2,155 @@ defmodule MicrowavepropWeb.BeaconLive.Index do @moduledoc false use MicrowavepropWeb, :live_view + use LiveTable.LiveResource, schema: Microwaveprop.Beacons.Beacon + alias Microwaveprop.Beacons alias Microwaveprop.Beacons.Beacon + def fields do + [ + id: %{label: "ID", hidden: true}, + frequency_mhz: %{ + label: "Frequency (MHz)", + sortable: true, + searchable: false, + renderer: &freq_cell/1 + }, + callsign: %{label: "Call", sortable: true, searchable: true}, + grid: %{label: "Grid", sortable: true, searchable: true}, + power_mw: %{label: "EIRP (mW)", sortable: true, renderer: &mw_cell/1}, + height_ft: %{label: "Height AGL (ft)", sortable: true}, + keying: %{label: "Keying", sortable: true, renderer: &keying_cell/1}, + on_the_air: %{label: "On air", sortable: true, renderer: &on_air_cell/1} + ] + end + + def filters, do: [] + + def actions do + [ + show: fn %{record: beacon} -> + assigns = %{beacon: beacon} + + ~H""" + <.link navigate={~p"/beacons/#{@beacon.id}"} class="btn btn-xs btn-ghost">Details + """ + end + ] + 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)) + |> assign(:data_provider, {Microwaveprop.Beacons, :approved_beacons_query, []}) + |> 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(:pending, beacon) + |> push_patch(to: socket.assigns.current_path |> path_with_prefix())} + 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) + |> push_patch(to: socket.assigns.current_path |> path_with_prefix())} + 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(:pending, pending, reset: true) + |> push_patch(to: socket.assigns.current_path |> path_with_prefix())} + end + + defp admin?(%{user: %{is_admin: true}}), do: true + defp admin?(_), do: false + + defp path_with_prefix(path) when is_binary(path), do: "/" <> path + defp path_with_prefix(_), do: "/beacons" + + 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 + + defp freq_cell(freq), do: Beacon.format_freq(freq) + defp mw_cell(power), do: Beacon.format_mw(power) + defp keying_cell(keying), do: Beacon.keying_label(keying) + + defp on_air_cell(on_the_air) do + assigns = %{on_the_air: on_the_air} + + ~H""" + + {if @on_the_air, do: "Yes", else: "No"} + + """ + end + @impl true def render(assigns) do ~H""" @@ -28,40 +174,13 @@ defmodule MicrowavepropWeb.BeaconLive.Index do > - <.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 :let={{_id, beacon}} label="Call">{beacon.callsign} - <:col :let={{_id, beacon}} label="Grid">{beacon.grid} - <:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)} - <:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft} - <:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)} - <:col :let={{_id, beacon}} label="On air"> - - {if beacon.on_the_air, do: "Yes", else: "No"} - - - <:action :let={{_id, beacon}}> -
- <.link navigate={~p"/beacons/#{beacon}"}>Show -
- <.link :if={admin?(@current_scope)} navigate={~p"/beacons/#{beacon}/edit"}>Edit - - <: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 - - - + <.live_table + fields={fields()} + filters={filters()} + options={@options} + streams={@streams} + actions={actions()} + />
<.header> @@ -105,102 +224,4 @@ defmodule MicrowavepropWeb.BeaconLive.Index do """ 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