Add wishlist section with paginated list
This commit is contained in:
@@ -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
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
<a href={~p"/records"} class={nav_link_class(assigns[:nav_section], :records)}>
|
<a href={~p"/records"} class={nav_link_class(assigns[:nav_section], :records)}>
|
||||||
<%= gettext("Collection") %>
|
<%= gettext("Collection") %>
|
||||||
</a>
|
</a>
|
||||||
|
<a href={~p"/wishlist"} class={nav_link_class(assigns[:nav_section], :wishlist)}>
|
||||||
|
<%= gettext("Wishlist") %>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-8">
|
<div class="flex space-x-8">
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<div>
|
||||||
|
<header class="gap-6 mb-2">
|
||||||
|
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||||
|
<%= gettext("All Records") %>
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center justify-between gap-6 mb-2 mt-2">
|
||||||
|
<form class="w-2/3" for={@record_list_params.query} phx-submit="search" phx-change="search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="w-full rounded-lg text-zinc-900 border-gray-300 focus:border-zinc-400 focus:ring-0 text-sm leading-6"
|
||||||
|
id={:query}
|
||||||
|
name={:query}
|
||||||
|
value={@record_list_params.query}
|
||||||
|
placeholder={gettext("Search")}
|
||||||
|
phx-debounce="500"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<.link patch={~p"/records/import"}>
|
||||||
|
<.button><%= gettext("Import") %></.button>
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="text-right text-base max-sm:text-xs mt-8">
|
||||||
|
<%= gettext(
|
||||||
|
"Showing <b>%{visible}</b> of <b>%{total}</b> records",
|
||||||
|
%{visible: Enum.count(@streams.records), total: @record_list_params.total_entries}
|
||||||
|
)
|
||||||
|
|> raw() %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-gray-100" role="list" id="records" phx-update="stream">
|
||||||
|
<li
|
||||||
|
:for={{id, record} <- @streams.records}
|
||||||
|
phx-click={JS.navigate(~p"/records/#{record}")}
|
||||||
|
class="flex justify-between gap-x-6 py-5 hover:bg-gray-50 px-2 -mx-2 md:px-4 md:-mx-4 rounded-lg cursor-pointer"
|
||||||
|
id={id}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 gap-x-4 items-center">
|
||||||
|
<img
|
||||||
|
class="w-20 flex-none rounded-lg"
|
||||||
|
alt={record.title}
|
||||||
|
src={~p"/covers/#{record.id}?vsn=#{record.cover_hash || ""}"}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-auto">
|
||||||
|
<h1 class="text-sm leading-6 text-zinc-700">
|
||||||
|
<.link
|
||||||
|
:for={artist <- record.artists}
|
||||||
|
class="hover:text-zinc-500"
|
||||||
|
patch={~p"/records?query=mbid:#{artist.musicbrainz_id}"}
|
||||||
|
>
|
||||||
|
<%= artist.name %>
|
||||||
|
</.link>
|
||||||
|
</h1>
|
||||||
|
<h2 class="mt-1 flex font-semibold text-sm sm:text-base leading-5 text-zinc-700 text-wrap">
|
||||||
|
<%= record.title %>
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-gray-500">
|
||||||
|
<%= Records.Record.format_release(record.release) %>
|
||||||
|
<span class="sm:hidden">
|
||||||
|
· <%= Records.Record.format_long_label(record.format) %> · <%= Records.Record.type_long_label(
|
||||||
|
record.type
|
||||||
|
) %>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-x-6">
|
||||||
|
<div class="hidden sm:flex sm:flex-col sm:items-end">
|
||||||
|
<p class="text-sm leading-6 text-gray-900">
|
||||||
|
<%= Records.Record.format_long_label(record.format) %>
|
||||||
|
<%= Records.Record.type_long_label(record.type) %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="-m-2.5 block p-2.5 text-gray-500 hover:text-gray-900"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="true"
|
||||||
|
phx-click={toggle_actions_menu(record.id)}
|
||||||
|
phx-click-away={close_actions_menu(record.id)}
|
||||||
|
>
|
||||||
|
<span class="sr-only"><%= gettext("Open options") %></span>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="icon"
|
||||||
|
>
|
||||||
|
<path d="M10 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM10 8.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM11.5 15.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!--
|
||||||
|
Dropdown menu, show/hide based on menu state.
|
||||||
|
|
||||||
|
Entering: "transition ease-out duration-100"
|
||||||
|
From: "transform opacity-0 scale-95"
|
||||||
|
To: "transform opacity-100 scale-100"
|
||||||
|
Leaving: "transition ease-in duration-75"
|
||||||
|
From: "transform opacity-100 scale-100"
|
||||||
|
To: "transform opacity-0 scale-95"
|
||||||
|
-->
|
||||||
|
<.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") %>
|
||||||
|
</.link>
|
||||||
|
<a
|
||||||
|
href={musicbrainz_url(record)}
|
||||||
|
target=".blank"
|
||||||
|
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}-musicbrainz"}
|
||||||
|
>
|
||||||
|
<%= gettext("View on MusicBrainz") %>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<.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>
|
||||||
|
|
||||||
|
<.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") %>
|
||||||
|
</.link>
|
||||||
|
</.focus_wrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<.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>
|
||||||
|
|
||||||
|
<.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=""
|
||||||
|
/>
|
||||||
|
</.modal>
|
||||||
|
|
||||||
|
<.pagination id={:bottom_pagination} pagination_params={@record_list_params} />
|
||||||
@@ -37,6 +37,8 @@ defmodule MusicLibraryWeb.Router do
|
|||||||
|
|
||||||
live "/records/:id", RecordLive.Show, :show
|
live "/records/:id", RecordLive.Show, :show
|
||||||
live "/records/:id/show/edit", RecordLive.Show, :edit
|
live "/records/:id/show/edit", RecordLive.Show, :edit
|
||||||
|
|
||||||
|
live "/wishlist", WishlistLive.Index, :index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user