Move all components under /components
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
defmodule MusicLibraryWeb.BarcodeScannerComponent do
|
||||
use MusicLibraryWeb, :live_component
|
||||
|
||||
alias MusicLibrary.Records
|
||||
alias MusicLibraryWeb.RecordComponents
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:camera, :pending)
|
||||
|> assign(:releases, [])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="barcode-scanner" phx-hook="BarcodeScanner" phx-target={@myself}>
|
||||
<header>
|
||||
<h1 class="text-sm font-medium leading-6 text-zinc-700 dark:text-zinc-400">
|
||||
{gettext("Scan one or more barcodes")}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="mt-4">
|
||||
<.camera_button camera={@camera} />
|
||||
<video :if={!(@camera == :denied)} class="w-full hidden h-96" id="camera-preview" playsinline />
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-zinc-100 dark:divide-slate-300/30 mt-5">
|
||||
<li
|
||||
:for={{status, record_id, release} <- @releases}
|
||||
id={release.id}
|
||||
class="flex justify-between gap-x-6 py-5 hover:bg-zinc-50 dark:hover:bg-zinc-700"
|
||||
phx-mounted={
|
||||
JS.transition(
|
||||
{"first:ease-in duration-300", "first:opacity-0 first:p-0 first:h-0",
|
||||
"first:opacity-100"},
|
||||
time: 300
|
||||
)
|
||||
}
|
||||
>
|
||||
<.release status={status} record_id={record_id} release={release} />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 flex justify-center">
|
||||
<.button
|
||||
disabled={length(@releases) == 0}
|
||||
phx-disable-with={gettext("Importing...")}
|
||||
phx-click={JS.push("import_releases", target: "#barcode-scanner")}
|
||||
>
|
||||
{gettext("Import releases")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :camera, :atom, required: true, values: [:pending, :allowed, :denied]
|
||||
|
||||
def camera_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
:if={!(@camera == :allowed)}
|
||||
id="camera-button"
|
||||
type="button"
|
||||
phx-click={
|
||||
JS.show(to: "#camera-preview")
|
||||
|> JS.dispatch("camera_request", to: "#barcode-scanner")
|
||||
|> JS.hide(to: "#camera-button")
|
||||
}
|
||||
class="relative block w-full h-96 rounded-lg border-2 border-dashed border-zinc-300 p-12 text-center hover:border-zinc-400 outline-none"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto size-12 text-zinc-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="mt-2 block text-sm font-semibold text-zinc-900 dark:text-zinc-400">
|
||||
{gettext("Open camera")}
|
||||
</span>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :release, MusicBrainz.ReleaseSearchResult, required: true
|
||||
attr :record_id, :string
|
||||
attr :status, :atom, required: true, values: [:collected, :wishlisted, :new]
|
||||
|
||||
defp release(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center justify-between w-full px-4">
|
||||
<div class="min-w-0 flex-auto">
|
||||
<h1 class="text-sm leading-6 text-zinc-700 dark:text-zinc-400">
|
||||
{@release.artists}
|
||||
</h1>
|
||||
<h2 class="mt-1 flex font-semibold text-sm sm:text-base leading-5 text-zinc-700 dark:text-zinc-300 text-wrap">
|
||||
{@release.title}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs leading-5 text-zinc-500 dark:text-zinc-400">
|
||||
{release_format_label(@release)} · {Records.Record.format_release(@release.date)} · {RecordComponents.type_label(
|
||||
@release.release_group.type
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<.badge :if={@status == :new} color={:gray} text={gettext("New")} />
|
||||
<.badge :if={@status == :wishlisted} color={:yellow} text={gettext("Wishlisted")} />
|
||||
<.badge :if={@status == :collected} color={:green} text={gettext("Collected")} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("camera_allowed", _params, socket) do
|
||||
Logger.debug(fn -> "Camera access allowed" end)
|
||||
{:noreply, assign(socket, camera: :allowed)}
|
||||
end
|
||||
|
||||
def handle_event("camera_denied", _params, socket) do
|
||||
Logger.debug(fn -> "Camera access denied" end)
|
||||
{:noreply, assign(socket, camera: :denied)}
|
||||
end
|
||||
|
||||
def handle_event("barcode_scanned", %{"number" => number}, socket) do
|
||||
Logger.debug(fn -> "Scanned barcode #{number}" end)
|
||||
|
||||
socket =
|
||||
case MusicBrainz.search_release_by_barcode(number) do
|
||||
{:ok, [best_match_release | _other_releases]} ->
|
||||
Logger.debug(fn -> "Found release #{best_match_release.id}" end)
|
||||
assign_release_with_status(best_match_release, socket)
|
||||
|
||||
{:ok, []} ->
|
||||
Logger.debug(fn -> "No release found for barcode #{number}" end)
|
||||
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("No release found for barcode %{number}", number: number)
|
||||
)
|
||||
|
||||
{:error, _reason} ->
|
||||
Logger.error(fn -> "Failed to search release for barcode #{number}" end)
|
||||
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to search release for barcode %{number}", number: number)
|
||||
)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("import_releases", _params, socket) do
|
||||
current_time = DateTime.utc_now()
|
||||
# TODO: error handling when a release fails to import
|
||||
:ok =
|
||||
Enum.each(socket.assigns.releases, fn {status, record_id, release} ->
|
||||
if status == :new do
|
||||
Records.import_from_musicbrainz_release(release.id,
|
||||
format: MusicBrainz.ReleaseSearchResult.format(release),
|
||||
purchased_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
if status == :wishlisted do
|
||||
record = Records.get_record!(record_id)
|
||||
Records.update_record(record, %{"purchased_at" => current_time})
|
||||
end
|
||||
end)
|
||||
|
||||
qs = %{order: :purchase}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:releases, [])
|
||||
|> put_flash(:info, gettext("Records imported successfully"))
|
||||
|> push_patch(to: ~p"/collection?#{qs}")}
|
||||
end
|
||||
|
||||
defp assign_release_with_status(release, socket) do
|
||||
format = MusicBrainz.ReleaseSearchResult.format(release)
|
||||
|
||||
release_with_status =
|
||||
case Records.get_release_status(release.id, format) do
|
||||
nil ->
|
||||
{:new, nil, release}
|
||||
|
||||
%{record_id: record_id, purchased_at: nil} ->
|
||||
{:wishlisted, record_id, release}
|
||||
|
||||
%{record_id: record_id} ->
|
||||
{:collected, record_id, release}
|
||||
end
|
||||
|
||||
assign(socket, :releases, [release_with_status | socket.assigns.releases])
|
||||
end
|
||||
|
||||
defp release_format_label(release) do
|
||||
release
|
||||
|> MusicBrainz.ReleaseSearchResult.format()
|
||||
|> RecordComponents.format_label()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,145 @@
|
||||
defmodule MusicLibraryWeb.FormComponent do
|
||||
use MusicLibraryWeb, :live_component
|
||||
|
||||
import MusicLibraryWeb.RecordComponents, only: [format_label: 1, type_label: 1]
|
||||
alias MusicLibrary.Records
|
||||
alias MusicLibrary.Records.Cover
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> allow_upload(:cover_data, accept: ~w(.jpg .jpeg .png .webp), max_entries: 1)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<header>
|
||||
<h1 class="text-sm font-medium leading-6 text-zinc-700 dark:text-zinc-400">
|
||||
{Enum.map(@record.artists, & &1.name) |> Enum.join(", ")}
|
||||
</h1>
|
||||
<h2 class="text-base font-medium leading-6 text-zinc-700 dark:text-zinc-400">
|
||||
{@record.title}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<.simple_form
|
||||
for={@form}
|
||||
id="record-form"
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<div class="sm:columns-2">
|
||||
<.input
|
||||
field={@form[:type]}
|
||||
type="select"
|
||||
label={gettext("Type")}
|
||||
prompt={gettext("Choose a value")}
|
||||
options={types_with_labels()}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:format]}
|
||||
type="select"
|
||||
label={gettext("Format")}
|
||||
prompt={gettext("Choose a value")}
|
||||
options={formats_with_labels()}
|
||||
/>
|
||||
</div>
|
||||
<.input field={@form[:musicbrainz_id]} type="text" label={gettext("MusicBrainz ID")} />
|
||||
<.input field={@form[:release]} type="text" label={gettext("Release")} />
|
||||
<.input
|
||||
:if={@show_purchased_at}
|
||||
field={@form[:purchased_at]}
|
||||
type="datetime-local"
|
||||
label={gettext("Purchased at")}
|
||||
/>
|
||||
<div phx-drop-target={@uploads.cover_data.ref}>
|
||||
<.label for={@uploads.cover_data.ref}>
|
||||
{gettext("Cover art")}
|
||||
</.label>
|
||||
<.error :for={{_index, msg} <- @uploads.cover_data.errors}>{msg}</.error>
|
||||
<span
|
||||
:if={@uploads.cover_data.entries == []}
|
||||
class="text-xs sm:text-sm float-right text-zinc-700 dark:text-zinc-400"
|
||||
>
|
||||
{gettext("No cover selected")}
|
||||
</span>
|
||||
<%= for entry <- @uploads.cover_data.entries do %>
|
||||
<span class="float-right text-zinc-700 dark:text-zinc-400">{entry.progress}%</span>
|
||||
<% end %>
|
||||
<.live_file_input
|
||||
class="mt-2 block w-full rounded-lg text-zinc-900 dark:text-zinc-200 focus:ring-0 text-xs sm:text-sm sm:leading-6"
|
||||
upload={@uploads.cover_data}
|
||||
/>
|
||||
</div>
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")}>{gettext("Save")}</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{record: record} = assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:form, fn ->
|
||||
to_form(Records.change_record(record))
|
||||
end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"record" => record_params}, socket) do
|
||||
changeset = Records.change_record(socket.assigns.record, record_params)
|
||||
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"record" => record_params}, socket) do
|
||||
uploaded_covers =
|
||||
consume_uploaded_entries(socket, :cover_data, fn %{path: path}, _entry ->
|
||||
{:ok, File.read!(path)}
|
||||
end)
|
||||
|
||||
save_record(socket, record_params, uploaded_covers)
|
||||
end
|
||||
|
||||
defp save_record(socket, record_params, uploaded_covers) do
|
||||
params =
|
||||
case uploaded_covers do
|
||||
[] ->
|
||||
record_params
|
||||
|
||||
[cover_data] ->
|
||||
{:ok, thumb_data} = Cover.resize(cover_data)
|
||||
Map.put(record_params, "cover_data", thumb_data)
|
||||
end
|
||||
|
||||
case Records.update_record(socket.assigns.record, params) do
|
||||
{:ok, record} ->
|
||||
notify_parent({:saved, record})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Record updated successfully"))
|
||||
|> push_patch(to: socket.assigns.patch)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def formats_with_labels do
|
||||
Enum.map(Records.Record.formats(), fn f -> {format_label(f), f} end)
|
||||
end
|
||||
|
||||
def types_with_labels do
|
||||
Enum.map(Records.Record.types(), fn t -> {type_label(t), t} end)
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
end
|
||||
@@ -0,0 +1,162 @@
|
||||
defmodule MusicLibraryWeb.ImportComponent do
|
||||
use MusicLibraryWeb, :live_component
|
||||
|
||||
import MusicLibraryWeb.RecordComponents,
|
||||
only: [toggle_actions_menu: 1, close_actions_menu: 1, format_label: 1, type_label: 1]
|
||||
|
||||
alias MusicLibrary.Records
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.simple_form
|
||||
for={@form}
|
||||
id={:import_form}
|
||||
phx-target={@myself}
|
||||
phx-change="search"
|
||||
phx-submit="search"
|
||||
class="px-4"
|
||||
>
|
||||
<.input
|
||||
id={:mb_query}
|
||||
name={:mb_query}
|
||||
field={@form[:mb_query]}
|
||||
type="text"
|
||||
label={gettext("Search for a record on MusicBrainz")}
|
||||
prompt={gettext("Search for records")}
|
||||
phx-debounce="500"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
autofocus
|
||||
/>
|
||||
</.simple_form>
|
||||
<ul
|
||||
id="release-groups"
|
||||
phx-update="stream"
|
||||
role="list"
|
||||
class="divide-y divide-zinc-100 dark:divide-slate-300/30 mt-5"
|
||||
>
|
||||
<li
|
||||
id="release-groups-empty"
|
||||
class="only:flex hidden items-center justify-center h-32 text-md text-zinc-500"
|
||||
>
|
||||
{gettext("No results")}
|
||||
</li>
|
||||
<.result
|
||||
:for={{id, release_group} <- @streams.release_groups}
|
||||
id={id}
|
||||
release_group={release_group}
|
||||
icon_name={@icon_name}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :icon_name, :string, required: true
|
||||
attr :release_group, MusicBrainz.ReleaseGroupSearchResult, required: true
|
||||
|
||||
defp result(assigns) do
|
||||
~H"""
|
||||
<li id={@id} class="flex justify-between gap-x-6 py-5 hover:bg-zinc-50 dark:hover:bg-zinc-700">
|
||||
<div class="shrink-0 flex items-center justify-between w-full px-4">
|
||||
<div class="min-w-0 flex-auto">
|
||||
<h1 class="text-sm leading-6 text-zinc-700 dark:text-zinc-400">
|
||||
{@release_group.artists}
|
||||
</h1>
|
||||
<h2 class="mt-1 flex font-semibold text-sm sm:text-base leading-5 text-zinc-700 dark:text-zinc-300 text-wrap">
|
||||
{@release_group.title}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs leading-5 text-zinc-500 dark:text-zinc-400">
|
||||
{Records.Record.format_release(@release_group.release)} · {type_label(@release_group.type)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="relative flex-none">
|
||||
<button
|
||||
type="button"
|
||||
class="text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-300"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
phx-click={toggle_actions_menu(@release_group.id)}
|
||||
phx-click-away={close_actions_menu(@release_group.id)}
|
||||
>
|
||||
<span class="sr-only">{gettext("Choose which format to import")}</span>
|
||||
<.icon name={@icon_name} class="-mt-1 h-5 w-5" aria-hidden="true" data-slot="icon" />
|
||||
</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-#{@release_group.id}"}
|
||||
class={[
|
||||
"hidden pointer-events-auto absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white dark:bg-zinc-800 py-2 shadow-lg ring-1 ring-zinc-900/5 focus:outline-none"
|
||||
]}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu-0-button"
|
||||
>
|
||||
<.link
|
||||
:for={format <- Records.Record.formats()}
|
||||
class="block px-3 py-1 text-sm leading-6 text-zinc-900 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
id={"actions-#{@release_group.id}-#{format}-import"}
|
||||
phx-click={
|
||||
JS.push("import", value: %{id: @release_group.id, format: format}, page_loading: true)
|
||||
}
|
||||
>
|
||||
{format_label(format)}
|
||||
</.link>
|
||||
</.focus_wrap>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :type, :string, required: true
|
||||
|
||||
defp type_badge(assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center rounded-md bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-600 ring-1 ring-inset ring-zinc-500/10">
|
||||
{@type}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> stream_configure(:release_groups,
|
||||
dom_id: fn rg -> "musicbrainz_#{rg.id}" end
|
||||
)
|
||||
|> stream(:release_groups, [])
|
||||
|> assign(:form, to_form(%{"mb_query" => ""}))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"mb_query" => mb_query}, socket) do
|
||||
{:ok, release_groups} = search(mb_query)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream(:release_groups, release_groups, reset: true)
|
||||
|> assign(:form, to_form(%{"mb_query" => mb_query}))}
|
||||
end
|
||||
|
||||
defp search(""), do: {:ok, []}
|
||||
|
||||
defp search(mb_query) do
|
||||
MusicBrainz.search_release_group(mb_query, limit: 10)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user