diff --git a/lib/microwaveprop_web/live/rover_locations_live.ex b/lib/microwaveprop_web/live/rover_locations_live.ex
index 828d4fb2..a7b3dbfa 100644
--- a/lib/microwaveprop_web/live/rover_locations_live.ex
+++ b/lib/microwaveprop_web/live/rover_locations_live.ex
@@ -277,8 +277,15 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
assigns = %{record: record, owner?: owner?(scope, record)}
~H"""
-
+
+ <.link
+ navigate={~p"/rover-locations/#{@record.id}"}
+ class="btn btn-xs btn-ghost"
+ >
+ View
+
+
"""
end
diff --git a/lib/microwaveprop_web/live/rover_locations_live/show.ex b/lib/microwaveprop_web/live/rover_locations_live/show.ex
new file mode 100644
index 00000000..f8c24c9c
--- /dev/null
+++ b/lib/microwaveprop_web/live/rover_locations_live/show.ex
@@ -0,0 +1,160 @@
+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,
+ assign(socket,
+ page_title: "Rover Location",
+ location: loc,
+ grid: Maidenhead.from_latlon(loc.lat, loc.lon, 10)
+ )}
+
+ nil ->
+ {:ok,
+ socket
+ |> put_flash(:error, "Location not found.")
+ |> push_navigate(to: ~p"/rover-locations")}
+ end
+ end
+
+ @impl true
+ def handle_event("delete", _params, socket) do
+ case current_user(socket.assigns[:current_scope]) do
+ %User{} = user ->
+ case Rover.delete_location(user, socket.assigns.location.id) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Location removed.")
+ |> push_navigate(to: ~p"/rover-locations")}
+
+ {: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 load_location(id) do
+ case Ecto.UUID.cast(id) do
+ {:ok, uuid} -> Location |> Repo.get(uuid) |> Repo.preload(:user)
+ :error -> nil
+ end
+ end
+
+ defp current_user(scope) do
+ case scope do
+ %{user: %User{} = user} -> user
+ _ -> nil
+ end
+ end
+
+ defp owner?(scope, %Location{user_id: user_id}) do
+ case current_user(scope) do
+ %User{id: ^user_id} -> true
+ _ -> false
+ end
+ end
+
+ defp status_label(:ideal), do: "Ideal"
+ defp status_label(:off_limits), do: "Off Limits"
+ defp status_label(_), do: ""
+
+ defp status_class(:ideal), do: "badge badge-success"
+ defp status_class(:off_limits), do: "badge badge-error"
+ defp status_class(_), do: "badge"
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ Rover Location
+ <:subtitle>
+ {status_label(@location.status)}
+ {@grid}
+
+ <:actions>
+ <.link navigate={~p"/rover-locations"} class="btn btn-ghost btn-sm">
+ <.icon name="hero-arrow-left" class="w-4 h-4" /> Back
+
+
+ <.icon name="hero-trash" class="w-4 h-4" /> Delete
+
+
+
+
+
+
+
+
- Grid
+ - {@grid}
+
+
+
- Coordinates
+ -
+ {Float.round(@location.lat, 6)}, {Float.round(@location.lon, 6)}
+
+
+
+
- Status
+ -
+
+ {status_label(@location.status)}
+
+
+
+
+
- Added
+ - {Calendar.strftime(@location.inserted_at, "%Y-%m-%d %H:%M UTC")}
+
+
+
- Submitted by
+ -
+ <.link
+ navigate={~p"/u/#{@location.user.callsign}"}
+ class="link link-primary font-mono"
+ >
+ {@location.user.callsign}
+
+
+
+
+
+
+
Notes
+
{@location.notes}
+
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex
index 2a9a1f81..79eaaead 100644
--- a/lib/microwaveprop_web/router.ex
+++ b/lib/microwaveprop_web/router.ex
@@ -197,6 +197,7 @@ defmodule MicrowavepropWeb.Router do
live "/skewt", SkewtLive
live "/rover", RoverLive
live "/rover-locations", RoverLocationsLive
+ live "/rover-locations/:id", RoverLocationsLive.Show
live "/algo", AlgoLive
live "/about", AboutLive
live "/privacy", PrivacyLive
diff --git a/test/microwaveprop_web/live/rover_locations_live/show_test.exs b/test/microwaveprop_web/live/rover_locations_live/show_test.exs
new file mode 100644
index 00000000..fdf9e9fe
--- /dev/null
+++ b/test/microwaveprop_web/live/rover_locations_live/show_test.exs
@@ -0,0 +1,68 @@
+defmodule MicrowavepropWeb.RoverLocationsLive.ShowTest do
+ use MicrowavepropWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias Microwaveprop.Rover
+
+ describe "show" do
+ test "renders the location and a map container", %{conn: conn} do
+ user = Microwaveprop.AccountsFixtures.user_fixture()
+
+ {:ok, loc} =
+ Rover.create_location(user, %{
+ lat: 32.5,
+ lon: -97.5,
+ status: :ideal,
+ notes: "great spot"
+ })
+
+ {:ok, lv, html} = live(conn, ~p"/rover-locations/#{loc.id}")
+
+ assert html =~ "Rover Location"
+ assert html =~ "great spot"
+ assert html =~ "EM12gm"
+ assert has_element?(lv, "#location-map-#{loc.id}[phx-hook=LocationMap]")
+ end
+
+ test "redirects when the location does not exist", %{conn: conn} do
+ missing_id = Ecto.UUID.generate()
+
+ assert {:error, {:live_redirect, %{to: "/rover-locations"}}} =
+ live(conn, ~p"/rover-locations/#{missing_id}")
+ end
+
+ test "redirects when the id is not a valid UUID", %{conn: conn} do
+ assert {:error, {:live_redirect, %{to: "/rover-locations"}}} =
+ live(conn, ~p"/rover-locations/not-a-uuid")
+ end
+
+ test "owner sees a Delete button; visitors do not", %{conn: conn} do
+ user = Microwaveprop.AccountsFixtures.user_fixture()
+ {:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :ideal})
+
+ # Anonymous: no Delete
+ {:ok, _lv, html} = live(conn, ~p"/rover-locations/#{loc.id}")
+ refute html =~ "Delete"
+
+ # Owner: Delete present
+ conn = log_in_user(conn, user)
+ {:ok, _lv, html} = live(conn, ~p"/rover-locations/#{loc.id}")
+ assert html =~ "Delete"
+ end
+
+ test "owner can delete from the show page and is redirected", %{conn: conn} do
+ user = Microwaveprop.AccountsFixtures.user_fixture()
+ {:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :ideal})
+ conn = log_in_user(conn, user)
+
+ {:ok, lv, _html} = live(conn, ~p"/rover-locations/#{loc.id}")
+
+ assert {:ok, _index_lv, _html} =
+ lv
+ |> element("button", "Delete")
+ |> render_click()
+ |> follow_redirect(conn, ~p"/rover-locations")
+ end
+ end
+end
diff --git a/test/microwaveprop_web/live/rover_locations_live_test.exs b/test/microwaveprop_web/live/rover_locations_live_test.exs
index b2ed7f68..1e363eda 100644
--- a/test/microwaveprop_web/live/rover_locations_live_test.exs
+++ b/test/microwaveprop_web/live/rover_locations_live_test.exs
@@ -121,6 +121,27 @@ defmodule MicrowavepropWeb.RoverLocationsLiveTest do
assert html =~ ~s(value="EM12gm")
end
+ test "View link on each row navigates to the show page", %{conn: conn, user: user} do
+ {:ok, loc} =
+ Rover.create_location(user, %{
+ lat: 32.5,
+ lon: -97.5,
+ status: :ideal,
+ notes: "rooftop spot"
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/rover-locations")
+
+ assert {:ok, _show_lv, html} =
+ lv
+ |> element("a[href='/rover-locations/#{loc.id}']", "View")
+ |> render_click()
+ |> follow_redirect(conn, ~p"/rover-locations/#{loc.id}")
+
+ assert html =~ "rooftop spot"
+ assert html =~ "EM12gm"
+ end
+
test "edit/delete buttons appear only on the owner's row", %{conn: conn, user: user} do
other = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, mine} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})