307 lines
9.8 KiB
Elixir
307 lines
9.8 KiB
Elixir
defmodule MicrowavepropWeb.RoverLocationsLive.Show do
|
|
@moduledoc "Detail page for a single rover location, with a map + marker."
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Accounts.User
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Rover
|
|
alias Microwaveprop.Rover.Location
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
case load_location(id) do
|
|
%Location{} = loc ->
|
|
{:ok,
|
|
socket
|
|
|> assign(
|
|
page_title: "Rover Location",
|
|
location: loc,
|
|
editing: false,
|
|
status_options: status_options()
|
|
)
|
|
|> assign_working_fields(loc)}
|
|
|
|
nil ->
|
|
{:ok,
|
|
socket
|
|
|> put_flash(:error, "Location not found.")
|
|
|> push_navigate(to: ~p"/rover-locations")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_edit", _params, socket) do
|
|
if can_modify?(socket.assigns[:current_scope], socket.assigns.location) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(editing: true)
|
|
|> push_event("set_marker_draggable", %{draggable: true})}
|
|
else
|
|
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel_edit", _params, socket) do
|
|
%Location{lat: lat, lon: lon} = location = socket.assigns.location
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign_working_fields(location)
|
|
|> assign(editing: false)
|
|
|> push_event("reset_marker", %{lat: lat, lon: lon})
|
|
|> push_event("set_marker_draggable", %{draggable: false})}
|
|
end
|
|
|
|
# Hook fires this on `dragend` with the marker's new coordinates.
|
|
# We only update the working preview — persistence waits for save.
|
|
def handle_event("location_dragged", %{"lat" => lat, "lon" => lon}, socket) when is_number(lat) and is_number(lon) do
|
|
{:noreply, assign_working_coords(socket, lat, lon)}
|
|
end
|
|
|
|
def handle_event("update_form", %{"location" => params}, socket) do
|
|
{:noreply,
|
|
assign(socket,
|
|
working_status: parse_status(Map.get(params, "status"), socket.assigns.working_status),
|
|
working_notes: Map.get(params, "notes", socket.assigns.working_notes)
|
|
)}
|
|
end
|
|
|
|
def handle_event("save_edit", _params, socket) do
|
|
%{
|
|
working_lat: lat,
|
|
working_lon: lon,
|
|
working_status: status,
|
|
working_notes: notes,
|
|
location: %Location{id: id}
|
|
} = socket.assigns
|
|
|
|
attrs = %{lat: lat, lon: lon, status: status, notes: notes}
|
|
|
|
with {:ok, user} <- authenticated(socket),
|
|
{:ok, updated} <- Rover.update_location(user, id, attrs) do
|
|
updated = Repo.preload(updated, :user)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(location: updated, editing: false)
|
|
|> assign_working_fields(updated)
|
|
|> put_flash(:info, "Location updated.")
|
|
|> push_event("set_marker_draggable", %{draggable: false})}
|
|
else
|
|
{:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in required.")}
|
|
{:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
|
|
{:error, %Ecto.Changeset{}} -> {:noreply, put_flash(socket, :error, "Could not save those coordinates.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("delete", _params, socket) do
|
|
with {:ok, user} <- authenticated(socket),
|
|
{:ok, _} <- Rover.delete_location(user, socket.assigns.location.id) do
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Location removed.")
|
|
|> push_navigate(to: ~p"/rover-locations")}
|
|
else
|
|
{:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in required.")}
|
|
{:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only delete your own locations.")}
|
|
end
|
|
end
|
|
|
|
defp assign_working_coords(socket, lat, lon) do
|
|
assign(socket,
|
|
working_lat: lat,
|
|
working_lon: lon,
|
|
grid: Maidenhead.from_latlon(lat, lon, 10)
|
|
)
|
|
end
|
|
|
|
defp assign_working_fields(socket, %Location{lat: lat, lon: lon, status: status, notes: notes}) do
|
|
socket
|
|
|> assign_working_coords(lat, lon)
|
|
|> assign(working_status: status, working_notes: notes || "")
|
|
end
|
|
|
|
defp status_options, do: [{"Good", "good"}, {"Bad", "bad"}]
|
|
|
|
defp parse_status("good", _fallback), do: :good
|
|
defp parse_status("bad", _fallback), do: :bad
|
|
defp parse_status(_, fallback), do: fallback
|
|
|
|
defp load_location(id) do
|
|
case Ecto.UUID.cast(id) do
|
|
{:ok, uuid} -> Location |> Repo.get(uuid) |> Repo.preload(:user)
|
|
:error -> nil
|
|
end
|
|
end
|
|
|
|
defp authenticated(%Phoenix.LiveView.Socket{assigns: %{current_scope: %{user: %User{} = user}}}), do: {:ok, user}
|
|
|
|
defp authenticated(_), do: {:error, :unauthenticated}
|
|
|
|
defp can_modify?(%{user: %User{is_admin: true}}, _), do: true
|
|
|
|
defp can_modify?(%{user: %User{id: id}}, %Location{user_id: id}) when not is_nil(id), do: true
|
|
|
|
defp can_modify?(_, _), do: false
|
|
|
|
defp status_label(:good), do: "Good"
|
|
defp status_label(:bad), do: "Bad"
|
|
defp status_label(_), do: ""
|
|
|
|
defp status_class(:good), do: "badge badge-success"
|
|
defp status_class(:bad), do: "badge badge-error"
|
|
defp status_class(_), do: "badge"
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
|
|
<.header>
|
|
Rover Location
|
|
<:subtitle>
|
|
<span class={status_class(@location.status)}>{status_label(@location.status)}</span>
|
|
{@grid}
|
|
<span :if={@editing} class="ml-2 badge badge-warning">Editing — drag the marker</span>
|
|
</:subtitle>
|
|
<:actions>
|
|
<.link navigate={~p"/rover-locations"} class="btn btn-ghost btn-sm">
|
|
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back
|
|
</.link>
|
|
|
|
<button
|
|
:if={can_modify?(@current_scope, @location) and not @editing}
|
|
type="button"
|
|
phx-click="toggle_edit"
|
|
class="btn btn-ghost btn-sm"
|
|
>
|
|
<.icon name="hero-pencil-square" class="w-4 h-4" /> Edit
|
|
</button>
|
|
|
|
<button
|
|
:if={@editing}
|
|
type="button"
|
|
phx-click="save_edit"
|
|
class="btn btn-primary btn-sm"
|
|
>
|
|
<.icon name="hero-check" class="w-4 h-4" /> Save
|
|
</button>
|
|
|
|
<button
|
|
:if={@editing}
|
|
type="button"
|
|
phx-click="cancel_edit"
|
|
class="btn btn-ghost btn-sm"
|
|
>
|
|
Cancel
|
|
</button>
|
|
|
|
<button
|
|
:if={can_modify?(@current_scope, @location) and not @editing}
|
|
type="button"
|
|
phx-click="delete"
|
|
data-confirm="Delete this location?"
|
|
class="btn btn-ghost btn-sm text-error"
|
|
>
|
|
<.icon name="hero-trash" class="w-4 h-4" /> Delete
|
|
</button>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<div class="card bg-base-100 border border-base-300 p-4 mb-4">
|
|
<dl class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
|
<div>
|
|
<dt class="text-base-content/60">Grid</dt>
|
|
<dd class="font-mono">{@grid}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-base-content/60">Coordinates</dt>
|
|
<dd class="font-mono">
|
|
{Float.round(@working_lat, 6)}, {Float.round(@working_lon, 6)}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-base-content/60">Status</dt>
|
|
<dd>
|
|
<span class={status_class(@working_status)}>
|
|
{status_label(@working_status)}
|
|
</span>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-base-content/60">Added</dt>
|
|
<dd>{Calendar.strftime(@location.inserted_at, "%Y-%m-%d %H:%M UTC")}</dd>
|
|
</div>
|
|
<div :if={@location.user}>
|
|
<dt class="text-base-content/60">Submitted by</dt>
|
|
<dd>
|
|
<.link
|
|
navigate={~p"/u/#{@location.user.callsign}"}
|
|
class="link link-primary font-mono"
|
|
>
|
|
{@location.user.callsign}
|
|
</.link>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div
|
|
:if={(not @editing and @location.notes) && @location.notes != ""}
|
|
class="mt-4"
|
|
>
|
|
<h3 class="text-sm text-base-content/60 mb-1">Notes</h3>
|
|
<p class="whitespace-pre-line text-sm">{@location.notes}</p>
|
|
</div>
|
|
|
|
<form
|
|
:if={@editing}
|
|
id="location-edit-form"
|
|
phx-change="update_form"
|
|
phx-submit="save_edit"
|
|
class="mt-4 space-y-3"
|
|
>
|
|
<div>
|
|
<label class="label" for="location-status">
|
|
<span class="label-text text-sm text-base-content/60">Status</span>
|
|
</label>
|
|
<select
|
|
id="location-status"
|
|
name="location[status]"
|
|
class="select select-bordered select-sm w-full max-w-xs"
|
|
>
|
|
<option
|
|
:for={{label, value} <- @status_options}
|
|
value={value}
|
|
selected={to_string(@working_status) == value}
|
|
>
|
|
{label}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="label" for="location-notes">
|
|
<span class="label-text text-sm text-base-content/60">Notes</span>
|
|
</label>
|
|
<textarea
|
|
id="location-notes"
|
|
name="location[notes]"
|
|
rows="3"
|
|
class="textarea textarea-bordered w-full"
|
|
>{@working_notes}</textarea>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div
|
|
id={"location-map-#{@location.id}"}
|
|
phx-hook="LocationMap"
|
|
phx-update="ignore"
|
|
data-lat={@location.lat}
|
|
data-lon={@location.lon}
|
|
class="h-[70vh] rounded border border-base-300 z-0"
|
|
>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|