defmodule MicrowavepropWeb.RoverLocationsLive do @moduledoc """ Globally-shared list of rover-friendly (or bad) parking locations. Everyone sees the table; only logged-in users can add or edit entries (and only the creator can edit/delete their own). """ use MicrowavepropWeb, :live_view use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Rover.Location import Ecto.Query alias Microwaveprop.Accounts.User alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo alias Microwaveprop.Rover alias Microwaveprop.Rover.Location @valid_status_filters ~w(good bad) @spec table_options() :: map() def table_options do %{sorting: %{default_sort: [inserted_at: :desc]}} end @spec fields() :: keyword() def fields do [ id: %{label: "ID", hidden: true}, user_id: %{label: "Owner", hidden: true}, status: %{label: "Status", sortable: true, renderer: &status_cell/1}, lat: %{label: "Location", sortable: true, renderer: &location_cell/2}, lon: %{label: "Lon", hidden: true}, notes: %{label: "Notes", searchable: true, renderer: ¬es_cell/1}, inserted_at: %{label: "Added (UTC)", sortable: true, renderer: &format_ts/1} ] end @spec filters() :: list() def filters, do: [] @doc false # Called by LiveTable to get the base queryable. Filters by status when # the user picks one in the in-page select; nil means show all. @spec visible_query_provider(status_filter :: term()) :: Ecto.Query.t() def visible_query_provider(status_filter) do base = from(l in Location, as: :resource) case status_filter do "good" -> from(l in base, where: l.status == :good) "bad" -> from(l in base, where: l.status == :bad) _ -> base end end @impl true def mount(_params, _session, socket) do {:ok, socket |> assign( page_title: "Rover Locations", form: nil, editing_id: nil, grid_input: "", status_filter: nil ) |> assign(:data_provider, {__MODULE__, :visible_query_provider, [nil]})} end @impl true def handle_event("filter_status", %{"value" => value}, socket) do status = parse_status(value) data_provider = {__MODULE__, :visible_query_provider, [status]} {:noreply, socket |> assign(:status_filter, status) |> assign(:data_provider, data_provider) |> reload()} end def handle_event("new", _params, socket) do if user = current_user(socket) do {:noreply, assign(socket, form: blank_form(user), editing_id: nil, grid_input: "")} else {:noreply, put_flash(socket, :error, "Sign in to add a location.")} end end def handle_event("edit", %{"id" => id}, socket) do user = current_user(socket) cond do is_nil(user) -> {:noreply, put_flash(socket, :error, "Sign in to edit a location.")} loc = editable_location(user, id) -> cs = Location.changeset(loc, %{}) grid = Maidenhead.from_latlon(loc.lat, loc.lon, 6) {:noreply, assign(socket, form: to_form(cs), editing_id: id, grid_input: grid)} true -> {:noreply, put_flash(socket, :error, "You can only edit your own locations.")} end end def handle_event("cancel", _, socket) do {:noreply, assign(socket, form: nil, editing_id: nil, grid_input: "")} end def handle_event("validate", %{"location" => params} = event_params, socket) do target = event_params["_target"] {params, grid_input} = sync_grid_and_latlon(params, target, socket.assigns.grid_input) base = if socket.assigns.editing_id, do: location_for_edit(socket), else: %Location{} cs = base |> Location.changeset(params) |> Map.put(:action, :validate) {:noreply, assign(socket, form: to_form(cs), grid_input: grid_input)} end def handle_event("save", %{"location" => params}, socket) do user = current_user(socket) cond do is_nil(user) -> {:noreply, put_flash(socket, :error, "Sign in required.")} socket.assigns.editing_id -> save_update(socket, user, params) true -> save_create(socket, user, params) end end def handle_event("delete", %{"id" => id}, socket) do case current_user(socket) do %User{} = user -> case Rover.delete_location(user, id) do {:ok, _} -> {:noreply, socket |> put_flash(:info, "Location removed.") |> reload()} {:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only delete your own locations.")} end _ -> {:noreply, put_flash(socket, :error, "Sign in required.")} end end defp save_create(socket, user, params) do case Rover.create_location(user, params) do {:ok, _loc} -> {:noreply, socket |> assign(form: nil, editing_id: nil) |> put_flash(:info, "Location added.") |> reload()} {:error, cs} -> {:noreply, assign(socket, form: to_form(cs))} end end defp save_update(socket, user, params) do case Rover.update_location(user, socket.assigns.editing_id, params) do {:ok, _loc} -> {:noreply, socket |> assign(form: nil, editing_id: nil) |> put_flash(:info, "Location updated.") |> reload()} {:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only edit your own locations.")} {:error, cs} -> {:noreply, assign(socket, form: to_form(cs))} end end defp location_for_edit(%{assigns: %{editing_id: id}}) when is_binary(id) do Repo.get(Location, id) || %Location{} end defp location_for_edit(_), do: %Location{} defp editable_location(%User{is_admin: true}, id) do Repo.get(Location, id) end defp editable_location(%User{id: user_id}, id) do case Repo.get(Location, id) do %Location{user_id: ^user_id} = loc -> loc _ -> nil end end defp blank_form(_user) do %Location{} |> Location.changeset(%{}) |> to_form() end # Re-runs the LiveTable fetch with the current data_provider + options. # Cheaper and simpler than push_patch — preserves search/sort/page state # without round-tripping through the URL. defp reload(socket) do options = socket.assigns.options data_provider = socket.assigns.data_provider table_options = LiveTable.TableConfig.get_table_options(table_options()) {resources, updated_options} = fetch_resources(options, data_provider) socket |> assign_to_socket(resources, table_options) |> assign(:options, updated_options) end defp parse_status(value) when value in @valid_status_filters, do: value defp parse_status(_), do: nil # Two-way grid <-> lat/lon sync. `_target` from the phx-change event # tells us which field the user actually edited; we mirror the change # into the other side. Unparseable input leaves the other side alone. defp sync_grid_and_latlon(params, ["location", "grid"], _old_grid) do raw = params |> Map.get("grid", "") |> to_string() |> String.trim() case Maidenhead.to_latlon(raw) do {:ok, {lat, lon}} -> params = params |> Map.put("lat", format_coord(lat)) |> Map.put("lon", format_coord(lon)) {params, raw} :error -> {params, raw} end end defp sync_grid_and_latlon(params, ["location", field], old_grid) when field in ["lat", "lon"] do with {lat, ""} <- params |> Map.get("lat", "") |> to_string() |> String.trim() |> Float.parse(), {lon, ""} <- params |> Map.get("lon", "") |> to_string() |> String.trim() |> Float.parse(), lat when lat >= -90.0 and lat <= 90.0 <- lat, lon when lon >= -180.0 and lon <= 180.0 <- lon do grid = Maidenhead.from_latlon(lat, lon, 6) {Map.put(params, "grid", grid), grid} else _ -> {params, old_grid} end end defp sync_grid_and_latlon(params, _target, old_grid), do: {params, old_grid} defp format_coord(value) do value |> Float.round(6) |> Float.to_string() end defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns) defp current_user(assigns) when is_map(assigns), do: scope_user(assigns) defp scope_user(assigns) do case assigns[:current_scope] do %{user: %User{} = user} -> user _ -> nil end end defp can_modify?(scope, %{user_id: uid}) do case scope_user(%{current_scope: scope}) do %User{is_admin: true} -> true %User{id: ^uid} when not is_nil(uid) -> true _ -> false end end defp can_modify?(_, _), do: false defp actions_for(scope) do [ buttons: fn %{record: record} -> row_actions(record, scope) end ] end defp row_actions(record, scope) do assigns = %{record: record, can_modify?: can_modify?(scope, record)} ~H"""
""" end defp status_cell(:good) do assigns = %{} ~H|Good| end defp status_cell(:bad) do assigns = %{} ~H|Bad| end defp status_cell(_), do: "" defp location_cell(_value, %{id: id, lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do grid = Maidenhead.from_latlon(lat, lon, 10) assigns = %{id: id, lat: lat, lon: lon, grid: grid} ~H""" <.link navigate={~p"/rover-locations/#{@id}"} class="flex flex-col hover:underline"> {@grid} {Float.round(@lat, 5)}, {Float.round(@lon, 5)} """ end defp location_cell(_, _), do: "" defp notes_cell(nil), do: "" defp notes_cell(""), do: "" defp notes_cell(notes) when is_binary(notes) do assigns = %{notes: notes} ~H|{@notes}| end defp format_ts(nil), do: "" defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d") defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d") defp format_ts(other), do: to_string(other) @impl true def render(assigns) do ~H""" <.header> Rover Locations <:subtitle>Community-shared parking spots for portable rover ops. <:actions> <.link navigate={~p"/rover-locations/map"} class="btn btn-ghost"> <.icon name="hero-map" class="w-5 h-5" /> Map

Sign in to add or edit locations.

{if @editing_id, do: "Edit location", else: "New location"}

<.form for={@form} phx-change="validate" phx-submit="save" id="location-form">
<.input name="location[grid]" value={@grid_input} type="text" label="Grid (Maidenhead)" placeholder="EM12kp" /> <.input field={@form[:status]} type="select" label="Status" options={[{"Good", :good}, {"Bad", :bad}]} />
<.input field={@form[:lat]} type="number" step="any" label="Latitude" placeholder="32.5" /> <.input field={@form[:lon]} type="number" step="any" label="Longitude" placeholder="-97.5" />
<.input field={@form[:notes]} type="textarea" label="Notes" rows="3" placeholder="Trail access, line-of-sight notes, contact info, etc." />
<.live_table fields={fields()} filters={filters()} options={@options} streams={@streams} actions={actions_for(@current_scope)} />
""" end end