defmodule MusicLibraryWeb.Components.BarcodeScanner do
use MusicLibraryWeb, :live_component
import MusicLibraryWeb.CartComponents, only: [cart_sidebar: 1]
alias MusicBrainz.ReleaseGroupSearchResult
alias MusicLibrary.BarcodeScan
alias MusicLibrary.Records
alias MusicLibraryWeb.ErrorMessages
alias MusicLibraryWeb.RecordComponents
require Logger
@impl true
def mount(socket) do
{:ok,
socket
|> assign(:camera, :pending)
|> assign(:scan_results, [])
|> assign(:cart_expanded?, true)}
end
@impl true
def render(assigns) do
~H"""
{gettext("Scan one or more barcodes")}
<.camera_button camera={@camera} />
<.cart_sidebar
count={length(@scan_results)}
expanded?={@cart_expanded?}
on_clear="clear_results"
on_toggle="toggle_cart"
target={@myself}
empty_heading={gettext("Your cart is empty")}
empty_subtext={gettext("Scan barcodes to add records.")}
>
<:empty_icon>
<.barcode_icon class="size-8 text-zinc-400" />
<:action>
<.button
variant="solid"
phx-disable-with={gettext("Adding...")}
phx-click={JS.push("import_releases", target: "#barcode-scanner")}
class="w-full"
>
{ngettext(
"Add %{count} release",
"Add %{count} releases",
length(@scan_results),
count: length(@scan_results)
)}
<.cart_item result={result} myself={@myself} />
"""
end
attr :camera, :atom, required: true, values: [:pending, :allowed, :denied]
def camera_button(assigns) do
~H"""
JS.dispatch("camera_request", to: "#barcode-scanner")
|> JS.hide(to: "#camera-button")
}
class="relative block h-96 w-full md:w-[512px] rounded-lg border-2 border-dashed border-zinc-300 p-12 text-center outline-hidden hover:border-zinc-400"
>
{gettext("Open camera")}
"""
end
attr :class, :string, required: true
def barcode_icon(assigns) do
~H"""
"""
end
attr :result, BarcodeScan.Result, required: true
attr :myself, :any, required: true
defp cart_item(%{result: %{status: :not_found}} = assigns) do
~H"""
<.barcode_icon class="size-12 flex-none rounded-md bg-red-50 p-2 text-red-400 dark:bg-red-950" />
{@result.number}
{gettext("Barcode not found")}
<.status_badge status={:not_found} />
{gettext("Remove")}
"""
end
defp cart_item(assigns) do
~H"""
~p"/images/cover-not-found.png" <> "';"}
/>
{@result.release.artists}
{@result.release.title}
{release_format_label(@result.release)} · {Records.Record.format_release_date(
@result.release.date
)} · {RecordComponents.type_label(@result.release.release_group.type)}
<.status_badge status={@result.status} record_id={@result.record_id} />
{gettext("Remove")}
"""
end
attr :status, :atom, required: true, values: [:new, :wishlisted, :collected, :not_found]
attr :record_id, :string, default: nil
defp status_badge(%{status: :new} = assigns) do
~H"""
<.badge class="block">{gettext("New")}
"""
end
defp status_badge(%{status: :wishlisted} = assigns) do
~H"""
<.link navigate={~p"/wishlist/#{@record_id}"}>
<.badge size="xs" color="warning" class="block">{gettext("Wishlisted")}
"""
end
defp status_badge(%{status: :collected} = assigns) do
~H"""
<.link navigate={~p"/collection/#{@record_id}"}>
<.badge size="xs" color="success" class="block">{gettext("Collected")}
"""
end
defp status_badge(%{status: :not_found} = assigns) do
~H"""
<.badge size="xs" color="danger" class="block">{gettext("Not found")}
"""
end
@impl true
def handle_event("camera_allowed", _params, socket) do
{:noreply, assign(socket, camera: :allowed)}
end
def handle_event("camera_denied", _params, socket) do
{:noreply, assign(socket, camera: :denied)}
end
def handle_event("barcode_scanned", %{"number" => number}, socket) do
socket =
case BarcodeScan.scan(number) do
{:ok, scan_result} ->
assign(socket, :scan_results, [scan_result | socket.assigns.scan_results])
{:error, reason} ->
Logger.error(fn ->
"Failed to search release for barcode #{number}: #{inspect(reason)}"
end)
put_toast(
socket,
:error,
gettext("Failed to search release for barcode %{number}", number: number)
)
end
{:noreply, socket}
end
def handle_event("remove_result", %{"number" => number}, socket) do
scan_results = Enum.reject(socket.assigns.scan_results, &(&1.number == number))
{:noreply,
socket
|> assign(:scan_results, scan_results)
|> push_event("remove_barcode", %{number: number})}
end
def handle_event("clear_results", _params, socket) do
{:noreply,
socket
|> assign(:scan_results, [])
|> push_event("clear_barcodes", %{})}
end
def handle_event("toggle_cart", _params, socket) do
{:noreply, assign(socket, :cart_expanded?, not socket.assigns.cart_expanded?)}
end
def handle_event("import_releases", _params, socket) do
current_time = DateTime.utc_now()
scan_results = socket.assigns.scan_results
socket =
if BarcodeScan.should_import_async?(scan_results) do
{:ok, sync_errors, async_count} =
BarcodeScan.import_results_async(scan_results, current_time)
socket
|> maybe_toast_errors(sync_errors)
|> put_toast(
:info,
ngettext(
"Importing %{count} record in the background...",
"Importing %{count} records in the background...",
async_count,
count: async_count
)
)
else
case BarcodeScan.import_results(scan_results, current_time) do
[] ->
put_toast(socket, :info, gettext("Records imported successfully"))
errors ->
maybe_toast_errors(socket, errors)
end
end
qs = %{order: :purchase}
{:noreply,
socket
|> assign(:scan_results, [])
|> push_patch(to: ~p"/collection?#{qs}")}
end
defp maybe_toast_errors(socket, []), do: socket
defp maybe_toast_errors(socket, errors) do
errors_summary =
Enum.map_join(errors, "\n", fn {number, reason} ->
"#{number}: #{ErrorMessages.friendly_message(reason)}"
end)
put_toast(
socket,
:error,
gettext("Some records could not be imported: %{summary}", summary: errors_summary)
)
end
defp release_format_label(release) do
release
|> MusicBrainz.ReleaseSearchResult.format()
|> RecordComponents.format_label()
end
end