Click a row's info area to expand a Leaflet mini-map centered on that pin, defaulting to ESRI World_Imagery satellite (zoom up to 21) with OSM/Topo toggles in a top-right layer control.
303 lines
9.7 KiB
Elixir
303 lines
9.7 KiB
Elixir
defmodule MicrowavepropWeb.RoverLocationsLive do
|
|
@moduledoc """
|
|
Globally-shared list of rover-friendly (or off-limits) parking
|
|
locations. Everyone sees the list; only logged-in users can add or
|
|
edit entries (and only the creator can edit/delete their own).
|
|
"""
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Accounts.User
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
alias Microwaveprop.Rover
|
|
alias Microwaveprop.Rover.Location
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Rover Locations",
|
|
locations: Rover.list_locations(),
|
|
form: nil,
|
|
editing_id: nil,
|
|
expanded_id: nil
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_map", %{"id" => id}, socket) do
|
|
new_id = if socket.assigns.expanded_id == id, do: nil, else: id
|
|
{:noreply, assign(socket, expanded_id: new_id)}
|
|
end
|
|
|
|
def handle_event("new", _params, socket) do
|
|
if user = current_user(socket) do
|
|
form = blank_form(user)
|
|
{:noreply, assign(socket, form: form, editing_id: nil)}
|
|
else
|
|
{:noreply, put_flash(socket, :error, "Sign in to add a location.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("edit", %{"id" => id}, socket) do
|
|
case current_user(socket) do
|
|
%User{id: user_id} ->
|
|
case Enum.find(socket.assigns.locations, &(&1.id == id)) do
|
|
%Location{user_id: ^user_id} = loc ->
|
|
cs = Location.changeset(loc, %{})
|
|
{:noreply, assign(socket, form: to_form(cs), editing_id: id)}
|
|
|
|
_ ->
|
|
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
|
|
end
|
|
|
|
_ ->
|
|
{:noreply, put_flash(socket, :error, "Sign in to edit a location.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel", _, socket) do
|
|
{:noreply, assign(socket, form: nil, editing_id: nil)}
|
|
end
|
|
|
|
def handle_event("validate", %{"location" => params}, socket) do
|
|
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))}
|
|
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
|
|
|> assign(locations: Rover.list_locations())
|
|
|> put_flash(:info, "Location removed.")}
|
|
|
|
{: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, locations: Rover.list_locations())
|
|
|> put_flash(:info, "Location added.")}
|
|
|
|
{: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, locations: Rover.list_locations())
|
|
|> put_flash(:info, "Location updated.")}
|
|
|
|
{: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(socket) do
|
|
Enum.find(socket.assigns.locations, &(&1.id == socket.assigns.editing_id)) || %Location{}
|
|
end
|
|
|
|
defp blank_form(_user) do
|
|
%Location{} |> Location.changeset(%{}) |> to_form()
|
|
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_edit?(_assigns, %Location{user_id: nil}), do: false
|
|
|
|
defp can_edit?(assigns, %Location{user_id: uid}) do
|
|
case current_user(assigns) do
|
|
%User{id: ^uid} -> true
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
defp status_label(:ideal), do: "Ideal"
|
|
defp status_label(:off_limits), do: "Off Limits"
|
|
|
|
defp status_class(:ideal), do: "badge badge-success"
|
|
defp status_class(:off_limits), do: "badge badge-error"
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
|
<div class="max-w-5xl mx-auto px-4 py-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h1 class="text-2xl font-semibold">Rover Locations</h1>
|
|
<p class="text-sm text-base-content/60">
|
|
Community-shared parking spots for portable rover ops.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
phx-click="new"
|
|
class="btn btn-primary btn-sm"
|
|
disabled={is_nil(current_user(assigns))}
|
|
>
|
|
+ Add location
|
|
</button>
|
|
</div>
|
|
|
|
<p :if={is_nil(current_user(assigns))} class="alert alert-info text-sm mb-4">
|
|
<a href={~p"/users/log-in"} class="link link-primary">Sign in</a> to add or edit locations.
|
|
</p>
|
|
|
|
<div :if={@form} class="card bg-base-200 p-4 mb-4">
|
|
<h2 class="text-lg font-semibold mb-2">
|
|
{if @editing_id, do: "Edit location", else: "New location"}
|
|
</h2>
|
|
<.form for={@form} phx-change="validate" phx-submit="save" id="location-form">
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<.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[:status]}
|
|
type="select"
|
|
label="Status"
|
|
options={[{"Ideal", :ideal}, {"Off Limits", :off_limits}]}
|
|
/>
|
|
</div>
|
|
<.input
|
|
field={@form[:notes]}
|
|
type="textarea"
|
|
label="Notes"
|
|
rows="3"
|
|
placeholder="Trail access, line-of-sight notes, contact info, etc."
|
|
/>
|
|
<div class="flex gap-2 mt-3">
|
|
<button type="submit" class="btn btn-primary btn-sm">
|
|
{if @editing_id, do: "Save", else: "Add"}
|
|
</button>
|
|
<button type="button" phx-click="cancel" class="btn btn-ghost btn-sm">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
|
|
<div :if={@locations == []} class="text-base-content/60 text-sm py-8 text-center">
|
|
No locations yet. Be the first to share one.
|
|
</div>
|
|
|
|
<ul :if={@locations != []} class="space-y-3">
|
|
<li
|
|
:for={loc <- @locations}
|
|
id={"location-#{loc.id}"}
|
|
class="card bg-base-100 border border-base-300 p-4"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div
|
|
class="flex-1 min-w-0 cursor-pointer"
|
|
phx-click="toggle_map"
|
|
phx-value-id={loc.id}
|
|
>
|
|
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
|
<span class={status_class(loc.status)}>{status_label(loc.status)}</span>
|
|
<span class="font-mono text-sm font-semibold">
|
|
{Maidenhead.from_latlon(loc.lat, loc.lon, 10)}
|
|
</span>
|
|
<span class="font-mono text-xs text-base-content/60">
|
|
{Float.round(loc.lat, 5)}, {Float.round(loc.lon, 5)}
|
|
</span>
|
|
</div>
|
|
<p :if={loc.notes && loc.notes != ""} class="text-sm whitespace-pre-line">
|
|
{loc.notes}
|
|
</p>
|
|
<p class="text-xs text-base-content/50 mt-1">
|
|
Added {Calendar.strftime(loc.inserted_at, "%Y-%m-%d")}
|
|
</p>
|
|
</div>
|
|
<div :if={can_edit?(assigns, loc)} class="flex gap-2 shrink-0">
|
|
<button
|
|
type="button"
|
|
phx-click="edit"
|
|
phx-value-id={loc.id}
|
|
class="btn btn-ghost btn-xs"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click="delete"
|
|
phx-value-id={loc.id}
|
|
data-confirm="Delete this location?"
|
|
class="btn btn-ghost btn-xs text-error"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
:if={@expanded_id == loc.id}
|
|
id={"location-map-#{loc.id}"}
|
|
phx-hook="LocationMap"
|
|
phx-update="ignore"
|
|
data-lat={loc.lat}
|
|
data-lon={loc.lon}
|
|
class="mt-3 h-80 rounded border border-base-300 z-0"
|
|
>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|