diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f52b22ff..9f2bd024 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -32,7 +32,8 @@ "Bash(sqlite3:*)", "WebSearch", "Bash(mix shellcheck:*)", - "Bash(mix compile:*)" + "Bash(mix compile:*)", + "mcp__tidewave__get_docs" ] }, "enableAllProjectMcpServers": false diff --git a/lib/music_library_web/live/record_set_live/index.html.heex b/lib/music_library_web/live/record_set_live/index.html.heex index 034a9a10..99a4fcce 100644 --- a/lib/music_library_web/live/record_set_live/index.html.heex +++ b/lib/music_library_web/live/record_set_live/index.html.heex @@ -55,7 +55,9 @@

- {record_set.name} + <.link navigate={~p"/record-sets/#{record_set}"} class="hover:underline"> + {record_set.name} +

{gettext("%{collected}/%{total} records", RecordSet.count_by_status(record_set))} diff --git a/lib/music_library_web/live/record_set_live/show.ex b/lib/music_library_web/live/record_set_live/show.ex new file mode 100644 index 00000000..0678f82a --- /dev/null +++ b/lib/music_library_web/live/record_set_live/show.ex @@ -0,0 +1,81 @@ +defmodule MusicLibraryWeb.RecordSetLive.Show do + use MusicLibraryWeb, :live_view + + import MusicLibraryWeb.RecordComponents, only: [artist_links: 1] + + alias MusicLibrary.RecordSets + alias MusicLibrary.RecordSets.RecordSet + alias MusicLibraryWeb.Markdown + alias Phoenix.LiveView.JS + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :current_section, :record_sets)} + end + + @impl true + def handle_params(%{"id" => id}, _url, socket) do + record_set = RecordSets.get_record_set!(id) + + {:noreply, + socket + |> assign(:record_set, record_set) + |> assign(:page_title, page_title(socket.assigns.live_action, record_set))} + end + + @impl true + def handle_info( + {MusicLibraryWeb.RecordSetLive.Form, {:updated, record_set}}, + socket + ) do + {:noreply, + socket + |> assign(:record_set, record_set) + |> assign(:page_title, page_title(socket.assigns.live_action, record_set))} + end + + def handle_info( + {MusicLibraryWeb.RecordSetLive.RecordPicker, {:added, record_set}}, + socket + ) do + {:noreply, assign(socket, :record_set, record_set)} + end + + @impl true + def handle_event("delete_set", _params, socket) do + {:ok, _} = RecordSets.delete_record_set(socket.assigns.record_set) + + {:noreply, push_navigate(socket, to: ~p"/record-sets")} + end + + def handle_event("remove_record", %{"record-id" => record_id}, socket) do + {:ok, updated_set} = + RecordSets.remove_record_from_set(socket.assigns.record_set, record_id) + + {:noreply, assign(socket, :record_set, updated_set)} + end + + def handle_event("move_up", %{"record-id" => record_id}, socket) do + {:ok, updated_set} = + RecordSets.move_record_in_set(socket.assigns.record_set, record_id, :up) + + {:noreply, assign(socket, :record_set, updated_set)} + end + + def handle_event("move_down", %{"record-id" => record_id}, socket) do + {:ok, updated_set} = + RecordSets.move_record_in_set(socket.assigns.record_set, record_id, :down) + + {:noreply, assign(socket, :record_set, updated_set)} + end + + defp page_title(:show, record_set), do: record_set.name + defp page_title(:edit, record_set), do: gettext("Edit") <> " · " <> record_set.name + defp page_title(:add_record, record_set), do: gettext("Add Record") <> " · " <> record_set.name + + defp render_description(description) do + description + |> Markdown.to_html() + |> raw() + end +end diff --git a/lib/music_library_web/live/record_set_live/show.html.heex b/lib/music_library_web/live/record_set_live/show.html.heex new file mode 100644 index 00000000..d7c52121 --- /dev/null +++ b/lib/music_library_web/live/record_set_live/show.html.heex @@ -0,0 +1,186 @@ + +
+
+
+

+ {@record_set.name} +

+ + {gettext("%{collected}/%{total} records", RecordSet.count_by_status(@record_set))} + +
+
+ <.button + variant="solid" + size="sm" + patch={~p"/record-sets/#{@record_set}/show/add-record"} + > + <.icon name="hero-plus" class="icon" aria-hidden="true" data-slot="icon" /> + {gettext("Add Record")} + + <.dropdown id="set-actions" placement="bottom-end"> + <:toggle> + <.button variant="ghost"> + {gettext("Actions")} + <.icon + name="hero-ellipsis-vertical" + class="h-5 w-5 text-zinc-500 dark:text-zinc-400 cursor-pointer" + aria-hidden="true" + data-slot="icon" + /> + + + <.dropdown_link + id="set-actions-edit" + patch={~p"/record-sets/#{@record_set}/show/edit"} + > + {gettext("Edit")} + + <.separator /> + <.dropdown_button + phx-click="delete_set" + data-confirm={gettext("Are you sure?")} + class={[ + "text-red-900! hover:bg-red-50! dark:text-red-500! dark:hover:bg-red-900/30! dark:hover:text-red-600!" + ]} + > + {gettext("Delete")} + + +
+
+ +
+ {render_description(@record_set.description)} +
+ +
+
+ <.icon name="hero-musical-note" class="h-12 w-12 text-zinc-400 mx-auto mb-4" /> +

+ {gettext("No records in this set yet")} +

+
+ +
+
+ <.link + :if={item.record.purchased_at} + navigate={~p"/collection/#{item.record}"} + > + + + <.link + :if={!item.record.purchased_at} + navigate={~p"/wishlist/#{item.record}"} + > + + +
+ <.dropdown + id={"item-actions-#{item.record.id}"} + placement="bottom-end" + > + <:toggle> + {gettext("Actions")} + <.icon + name="hero-ellipsis-vertical" + class="size-5 text-zinc-800 dark:text-zinc-200 cursor-pointer" + aria-hidden="true" + data-slot="icon" + /> + + <.dropdown_button + :if={item.position > 0} + phx-click="move_up" + phx-value-record-id={item.record.id} + > + {gettext("Move left")} + + <.dropdown_button + :if={item.position < length(@record_set.items) - 1} + phx-click="move_down" + phx-value-record-id={item.record.id} + > + {gettext("Move right")} + + <.separator /> + <.dropdown_button + phx-click="remove_record" + phx-value-record-id={item.record.id} + data-confirm={gettext("Remove this record from the set?")} + class={[ + "text-red-900! hover:bg-red-50! dark:text-red-500! dark:hover:bg-red-900/30! dark:hover:text-red-600!" + ]} + > + {gettext("Remove")} + + +
+

+ <.artist_links joinphrase_class="text-xs" artists={item.record.artists} /> +

+

+ {item.record.title} +

+
+
+
+
+ + <.structured_modal + :if={@live_action == :edit} + id="record-set-modal" + on_close={JS.patch(~p"/record-sets/#{@record_set}")} + > + <.live_component + module={MusicLibraryWeb.RecordSetLive.Form} + id={@record_set.id} + title={@page_title} + action={@live_action} + record_set={@record_set} + patch={~p"/record-sets/#{@record_set}"} + /> + + + <.structured_modal + :if={@live_action == :add_record} + id="record-picker-modal" + on_close={JS.patch(~p"/record-sets/#{@record_set}")} + > + <.live_component + module={MusicLibraryWeb.RecordSetLive.RecordPicker} + id={"record-picker-#{@record_set.id}"} + title={@page_title} + record_set={@record_set} + patch={~p"/record-sets/#{@record_set}"} + /> + +
diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex index 2915a7d8..3330d424 100644 --- a/lib/music_library_web/router.ex +++ b/lib/music_library_web/router.ex @@ -70,6 +70,10 @@ defmodule MusicLibraryWeb.Router do live "/record-sets/:id/edit", RecordSetLive.Index, :edit live "/record-sets/:id/add-record", RecordSetLive.Index, :add_record + live "/record-sets/:id", RecordSetLive.Show, :show + live "/record-sets/:id/show/edit", RecordSetLive.Show, :edit + live "/record-sets/:id/show/add-record", RecordSetLive.Show, :add_record + live "/scrobble-rules", ScrobbleRulesLive.Index, :index live "/scrobble-rules/new", ScrobbleRulesLive.Index, :new live "/scrobble-rules/:id/edit", ScrobbleRulesLive.Index, :edit diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 9f1414b2..27a72e58 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -15,6 +15,7 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -40,6 +41,7 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -55,6 +57,8 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.ex +#: lib/music_library_web/live/record_set_live/show.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.ex @@ -908,6 +912,7 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -1699,6 +1704,8 @@ msgid "Regenerate embeddings" msgstr "" #: lib/music_library_web/live/record_set_live/index.ex +#: lib/music_library_web/live/record_set_live/show.ex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Add Record" msgstr "" @@ -1714,11 +1721,13 @@ msgid "Edit Set" msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Move left" msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Move right" msgstr "" @@ -1765,11 +1774,13 @@ msgid "Record set updated successfully" msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Remove" msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Remove this record from the set?" msgstr "" @@ -1800,6 +1811,7 @@ msgid "Search your records to add a record to this set." msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "%{collected}/%{total} records" msgstr "" @@ -1808,3 +1820,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Updated" msgstr "" + +#: lib/music_library_web/live/record_set_live/show.html.heex +#, elixir-autogen, elixir-format +msgid "No records in this set yet" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 72f42f74..8f552c7d 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -15,6 +15,7 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -40,6 +41,7 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -55,6 +57,8 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.ex +#: lib/music_library_web/live/record_set_live/show.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.ex @@ -908,6 +912,7 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -1699,6 +1704,8 @@ msgid "Regenerate embeddings" msgstr "" #: lib/music_library_web/live/record_set_live/index.ex +#: lib/music_library_web/live/record_set_live/show.ex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Add Record" msgstr "" @@ -1714,11 +1721,13 @@ msgid "Edit Set" msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Move left" msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Move right" msgstr "" @@ -1765,11 +1774,13 @@ msgid "Record set updated successfully" msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Remove" msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "Remove this record from the set?" msgstr "" @@ -1800,6 +1811,7 @@ msgid "Search your records to add a record to this set." msgstr "" #: lib/music_library_web/live/record_set_live/index.html.heex +#: lib/music_library_web/live/record_set_live/show.html.heex #, elixir-autogen, elixir-format msgid "%{collected}/%{total} records" msgstr "" @@ -1808,3 +1820,8 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "Updated" msgstr "" + +#: lib/music_library_web/live/record_set_live/show.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "No records in this set yet" +msgstr "" diff --git a/test/music_library_web/live/record_set_live/show_test.exs b/test/music_library_web/live/record_set_live/show_test.exs new file mode 100644 index 00000000..aba0a55c --- /dev/null +++ b/test/music_library_web/live/record_set_live/show_test.exs @@ -0,0 +1,110 @@ +defmodule MusicLibraryWeb.RecordSetLive.ShowTest do + use MusicLibraryWeb.ConnCase + + import MusicLibrary.Fixtures.RecordSets + import Phoenix.LiveViewTest + + alias MusicLibrary.RecordSets + + describe "Show" do + test "displays record set name", %{conn: conn} do + set = record_set(%{name: "My Favorites"}) + + conn + |> visit(~p"/record-sets/#{set}") + |> assert_has("h1", text: "My Favorites") + end + + test "displays collected/total count", %{conn: conn} do + {set, _records} = record_set_with_records(2) + + conn + |> visit(~p"/record-sets/#{set}") + |> assert_has("span", text: "2/2 records") + end + + test "displays records in the set", %{conn: conn} do + {set, [r1, r2]} = record_set_with_records(2) + + conn + |> visit(~p"/record-sets/#{set}") + |> assert_has("h2", text: escape(r1.title)) + |> assert_has("h2", text: escape(r2.title)) + end + + test "shows empty state when set has no records", %{conn: conn} do + set = record_set() + + conn + |> visit(~p"/record-sets/#{set}") + |> assert_has("p", text: "No records in this set yet") + end + + test "renders markdown description as HTML", %{conn: conn} do + set = record_set(%{description: "This is **bold** text"}) + + conn + |> visit(~p"/record-sets/#{set}") + |> assert_has("article strong", text: "bold") + end + end + + describe "Remove record" do + test "removes a record from the set", %{conn: conn} do + {set, [r1 | _]} = record_set_with_records(2) + + {:ok, view, _html} = live(conn, ~p"/record-sets/#{set}") + + view + |> element("button[phx-click='remove_record'][phx-value-record-id='#{r1.id}']") + |> render_click() + + updated = RecordSets.get_record_set!(set.id) + refute Enum.any?(updated.items, &(&1.record.id == r1.id)) + end + end + + describe "Reorder records" do + test "moves a record right (down)", %{conn: conn} do + {set, [r1, r2 | _]} = record_set_with_records(3) + + {:ok, view, _html} = live(conn, ~p"/record-sets/#{set}") + + view + |> element("button[phx-click='move_down'][phx-value-record-id='#{r1.id}']") + |> render_click() + + updated = RecordSets.get_record_set!(set.id) + ids_in_order = Enum.map(updated.items, & &1.record.id) + assert Enum.at(ids_in_order, 0) == r2.id + assert Enum.at(ids_in_order, 1) == r1.id + end + end + + describe "Delete set" do + test "deletes set and navigates to index", %{conn: conn} do + set = record_set(%{name: "To Delete"}) + + {:ok, view, _html} = live(conn, ~p"/record-sets/#{set}") + + view + |> element("button[phx-click='delete_set']") + |> render_click() + + assert_redirect(view, ~p"/record-sets") + assert_raise Ecto.NoResultsError, fn -> RecordSets.get_record_set!(set.id) end + end + end + + describe "Navigation from index" do + test "index links to show page", %{conn: conn} do + set = record_set(%{name: "Linked Set"}) + + conn + |> visit(~p"/record-sets") + |> click_link("Linked Set") + |> assert_has("h1", text: "Linked Set") + |> assert_path(~p"/record-sets/#{set}") + end + end +end