diff --git a/lib/music_library/wishlist.ex b/lib/music_library/wishlist.ex
new file mode 100644
index 00000000..54c38555
--- /dev/null
+++ b/lib/music_library/wishlist.ex
@@ -0,0 +1,61 @@
+defmodule MusicLibrary.Wishlist do
+ import Ecto.Query, warn: false
+
+ alias MusicLibrary.Repo
+ alias MusicLibrary.Records.{Record, SearchParser}
+
+ @fields [:id, :type, :artists, :format, :title, :release, :genres, :musicbrainz_id, :cover_hash]
+
+ def search_records(query, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+ offset = Keyword.get(opts, :offset, 0)
+
+ search =
+ query
+ |> build_search()
+ |> limit(^limit)
+ |> offset(^offset)
+ |> select(^@fields)
+
+ Repo.all(search)
+ end
+
+ def search_records_count(query) do
+ search = build_search(query)
+
+ Repo.aggregate(search, :count)
+ end
+
+ defp build_search(query) do
+ {:ok, parsed_query} = SearchParser.parse(query)
+
+ base_search =
+ from r in Record,
+ where: is_nil(r.purchased_at),
+ order_by: [r.artists[0]["sort_name"], r.title]
+
+ Enum.reduce(parsed_query, base_search, fn
+ {:artist, artist}, search ->
+ search |> where([r], like(r.artists, ^"%#{artist}%"))
+
+ {:album, album}, search ->
+ search |> where([r], like(r.title, ^"%#{album}%"))
+
+ {:mbid, mbid}, search ->
+ search |> where([r], r.musicbrainz_id == ^mbid or like(r.artists, ^"%#{mbid}%"))
+
+ {:format, format}, search ->
+ search |> where([r], r.format == ^format)
+
+ {:type, type}, search ->
+ search |> where([r], r.type == ^type)
+
+ {:query, raw_query}, search ->
+ search
+ |> where(
+ [r],
+ like(r.title, ^"%#{raw_query}%") or like(r.artists, ^"%#{raw_query}%")
+ )
+ end)
+ end
+end
diff --git a/lib/music_library_web/components/layouts/app.html.heex b/lib/music_library_web/components/layouts/app.html.heex
index ec2b6af9..caac9ad6 100644
--- a/lib/music_library_web/components/layouts/app.html.heex
+++ b/lib/music_library_web/components/layouts/app.html.heex
@@ -9,6 +9,9 @@
<%= gettext("Collection") %>
+
+ <%= gettext("Wishlist") %>
+
diff --git a/lib/music_library_web/live/wishlist_live/index.ex b/lib/music_library_web/live/wishlist_live/index.ex
new file mode 100644
index 00000000..07248318
--- /dev/null
+++ b/lib/music_library_web/live/wishlist_live/index.ex
@@ -0,0 +1,99 @@
+defmodule MusicLibraryWeb.WishlistLive.Index do
+ use MusicLibraryWeb, :live_view
+ import MusicLibraryWeb.Pagination
+
+ alias MusicLibrary.Wishlist
+ alias MusicLibrary.Records
+
+ @default_records_list_params %{
+ query: "",
+ page: 1,
+ page_size: 100
+ }
+
+ @impl true
+ def mount(_params, _session, socket) do
+ socket =
+ if static_changed?(socket) do
+ put_flash(socket, :warning, gettext("The application has been updated, please reload."))
+ else
+ socket
+ end
+
+ {:ok, assign(socket, :nav_section, :wishlist)}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ defp apply_action(socket, :index, params) do
+ query = params["query"] || ""
+ total_records = Wishlist.search_records_count(query)
+
+ record_list_params =
+ @default_records_list_params
+ |> merge_query(query)
+ |> merge_pagination(params, total_records)
+
+ offset = page_to_offset(record_list_params.page, record_list_params.page_size)
+ records = Wishlist.search_records(query, limit: record_list_params.page_size, offset: offset)
+
+ socket
+ |> assign(:page_title, gettext("Collection"))
+ |> assign(:record, nil)
+ |> assign(:record_list_params, record_list_params)
+ |> stream(:records, records, reset: true)
+ end
+
+ @impl true
+ def handle_info({MusicLibraryWeb.RecordLive.FormComponent, {:saved, record}}, socket) do
+ {:noreply, stream_insert(socket, :records, record)}
+ end
+
+ @impl true
+ def handle_event("search", %{"query" => query}, socket) do
+ qs =
+ @default_records_list_params
+ |> Map.put(:query, query)
+ |> Map.take([:query, :page, :page_size])
+ |> URI.encode_query()
+
+ {:noreply, push_patch(socket, to: ~s"/records?#{qs}")}
+ end
+
+ defp merge_query(record_list_params, query) do
+ Map.put(record_list_params, :query, query)
+ end
+
+ defp merge_pagination(record_list_params, params, total_records) do
+ record_list_params
+ |> Map.put(:page, parse_int_or_default(params["page"], record_list_params.page))
+ |> Map.put(
+ :page_size,
+ parse_int_or_default(params["page_size"], record_list_params.page_size)
+ )
+ |> Map.put(:total_entries, total_records)
+ end
+
+ defp parse_int_or_default(nil, default), do: default
+
+ defp parse_int_or_default(value, _default) when is_binary(value) do
+ String.to_integer(value)
+ end
+
+ defp musicbrainz_url(record) do
+ "https://musicbrainz.org/release-group/#{record.musicbrainz_id}"
+ end
+
+ defp toggle_actions_menu(record_id) do
+ JS.toggle(to: "#actions-#{record_id}")
+ |> JS.toggle_class("pointer-events-none", to: "#records > li")
+ end
+
+ def close_actions_menu(record_id) do
+ JS.hide(to: "#actions-#{record_id}")
+ |> JS.remove_class("pointer-events-none", to: "#records > li")
+ end
+end
diff --git a/lib/music_library_web/live/wishlist_live/index.html.heex b/lib/music_library_web/live/wishlist_live/index.html.heex
new file mode 100644
index 00000000..c7d65a1a
--- /dev/null
+++ b/lib/music_library_web/live/wishlist_live/index.html.heex
@@ -0,0 +1,187 @@
+
+
+
+
+ <%= gettext(
+ "Showing %{visible} of %{total} records",
+ %{visible: Enum.count(@streams.records), total: @record_list_params.total_entries}
+ )
+ |> raw() %>
+
+
+
+
+ -
+
+

+
+
+ <.link
+ :for={artist <- record.artists}
+ class="hover:text-zinc-500"
+ patch={~p"/records?query=mbid:#{artist.musicbrainz_id}"}
+ >
+ <%= artist.name %>
+
+
+
+ <%= record.title %>
+
+
+ <%= Records.Record.format_release(record.release) %>
+
+ · <%= Records.Record.format_long_label(record.format) %> · <%= Records.Record.type_long_label(
+ record.type
+ ) %>
+
+
+
+
+
+
+
+ <%= Records.Record.format_long_label(record.format) %>
+ <%= Records.Record.type_long_label(record.type) %>
+
+
+
+
+
+ <.focus_wrap
+ id={"actions-#{record.id}"}
+ class={[
+ "hidden pointer-events-auto absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none"
+ ]}
+ role="menu"
+ aria-orientation="vertical"
+ aria-labelledby="options-menu-0-button"
+ tabindex="-1"
+ >
+ <.link
+ class="block px-3 py-1 text-sm leading-6 text-gray-900 hover:bg-gray-50"
+ role="menuitem"
+ tabindex="-1"
+ id={"actions-#{record.id}-show"}
+ navigate={~p"/records/#{record}"}
+ >
+ <%= gettext("Show") %>
+
+
+ <%= gettext("View on MusicBrainz") %>
+
+
+ <.link
+ class="block px-3 py-1 text-sm leading-6 text-gray-900 hover:bg-gray-50"
+ role="menuitem"
+ tabindex="-1"
+ id={"actions-#{record.id}-edit"}
+ patch={~p"/records/#{record}/edit"}
+ >
+ <%= gettext("Edit") %>
+
+
+ <.link
+ class="block px-3 py-1 text-sm leading-6 text-red-900 hover:bg-red-50"
+ role="menuitem"
+ tabindex="-1"
+ id={"actions-#{record.id}-delete"}
+ phx-click={JS.push("delete", value: %{id: record.id}) |> hide("##{id}")}
+ data-confirm={gettext("Are you sure?")}
+ >
+ <%= gettext("Delete") %>
+
+
+
+
+
+
+
+<.modal :if={@live_action == :edit} id="record-modal" show on_cancel={JS.patch(~p"/records")}>
+ <.live_component
+ module={MusicLibraryWeb.RecordLive.FormComponent}
+ id={@record.id}
+ title={@page_title}
+ action={@live_action}
+ record={@record}
+ patch={~p"/records"}
+ />
+
+
+<.modal :if={@live_action == :import} id="record-modal" show on_cancel={JS.patch(~p"/records")}>
+ <.live_component
+ module={MusicLibraryWeb.RecordLive.ImportComponent}
+ id={:search}
+ title={@page_title}
+ action={@live_action}
+ record={@record}
+ patch={~p"/records"}
+ initial_query=""
+ />
+
+
+<.pagination id={:bottom_pagination} pagination_params={@record_list_params} />
diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex
index 224d123c..7c6e7d94 100644
--- a/lib/music_library_web/router.ex
+++ b/lib/music_library_web/router.ex
@@ -37,6 +37,8 @@ defmodule MusicLibraryWeb.Router do
live "/records/:id", RecordLive.Show, :show
live "/records/:id/show/edit", RecordLive.Show, :edit
+
+ live "/wishlist", WishlistLive.Index, :index
end
end