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""" """ 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} />
    """ end defp cart_item(assigns) do ~H""" {@result.release.release_group.title} ~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} />
    """ 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