Use cart with barcode scanner

This commit is contained in:
Claudio Ortolina
2026-04-20 15:46:40 +01:00
parent d47df899b7
commit 9851a5c6bf
5 changed files with 280 additions and 107 deletions
@@ -14,49 +14,134 @@ defmodule MusicLibraryWeb.Components.BarcodeScanner do
{:ok, {:ok,
socket socket
|> assign(:camera, :pending) |> assign(:camera, :pending)
|> assign(:scan_results, [])} |> assign(:scan_results, [])
|> assign(:cart_expanded?, true)}
end end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="min-w-72" id="barcode-scanner" phx-hook=".BarcodeScanner" phx-target={@myself}> <div id="barcode-scanner" phx-hook=".BarcodeScanner" phx-target={@myself}>
<header> <div class="grid grid-cols-1 md:grid-cols-5">
<h1 class="text-sm/6 font-medium text-zinc-700 dark:text-zinc-400"> <section class="md:col-span-3 md:p-4 md:border-r md:border-zinc-200 md:dark:border-zinc-800">
{gettext("Scan one or more barcodes")} <header>
</h1> <h1 class="text-sm/6 font-medium text-zinc-700 dark:text-zinc-400">
</header> {gettext("Scan one or more barcodes")}
<div class="mt-4"> </h1>
<.camera_button camera={@camera} /> </header>
<video :if={!(@camera == :denied)} class="hidden h-96 w-full" id="camera-preview" playsinline /> <div class="mt-4">
</div> <.camera_button camera={@camera} />
<video
:if={!(@camera == :denied)}
class="hidden h-96 w-full"
id="camera-preview"
playsinline
/>
</div>
</section>
<ul class="mt-5 divide-y divide-zinc-100 dark:divide-slate-300/30"> <aside class={[
<li "md:col-span-2",
:for={scan_result <- @scan_results} "border-t md:border-t-0 md:border-l md:border-zinc-200 md:dark:border-zinc-800",
id={scan_result.number} "flex flex-col"
class="flex justify-between gap-x-6 py-5" ]}>
phx-mounted={ <div class="px-4 py-3 flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800">
JS.transition( <div class="flex items-center gap-2">
{"first:ease-in duration-300", "first:opacity-0 first:p-0 first:h-0", <p class="text-sm font-semibold text-zinc-700 dark:text-zinc-300">
"first:opacity-100"}, {gettext("Cart")}
time: 300 </p>
) <span class="text-xs text-zinc-500 dark:text-zinc-400">
} {ngettext(
> "%{count} record",
<.scan_result scan_result={scan_result} /> "%{count} records",
</li> length(@scan_results),
</ul> count: length(@scan_results)
)}
</span>
</div>
<div class="flex items-center gap-3">
<button
:if={@scan_results != []}
type="button"
phx-click="clear_results"
phx-target={@myself}
class="text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
>
{gettext("Clear all")}
</button>
<button
type="button"
phx-click="toggle_cart"
phx-target={@myself}
class="rounded-md p-1 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-800 md:hidden"
aria-label={gettext("Toggle cart")}
>
<.icon
name={if @cart_expanded?, do: "hero-chevron-down", else: "hero-chevron-up"}
class="size-4"
aria-hidden="true"
data-slot="icon"
/>
</button>
</div>
</div>
<div class="mt-4 flex justify-center"> <div class={["md:!block", not @cart_expanded? && "hidden"]}>
<.button <div
variant="solid" :if={@scan_results == []}
disabled={length(@scan_results) == 0} id="cart-empty"
phx-disable-with={gettext("Adding...")} class="flex flex-col items-center justify-center gap-2 px-6 py-10 text-center"
phx-click={JS.push("import_releases", target: "#barcode-scanner")} >
> <.barcode_icon class="size-8 text-zinc-400" />
{gettext("Add releases")} <p class="text-sm text-zinc-500 dark:text-zinc-400">
</.button> {gettext("Your cart is empty")}
</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400">
{gettext("Scan barcodes to add records.")}
</p>
</div>
<ul
:if={@scan_results != []}
id="cart-items"
class="divide-y divide-zinc-200 dark:divide-zinc-800 md:max-h-[calc(100vh-20rem)] overflow-y-auto"
>
<li
:for={result <- @scan_results}
id={"cart-item-#{result.number}"}
class="flex gap-3 px-4 py-3"
phx-mounted={
JS.transition(
{"first:ease-in duration-300", "first:opacity-0 first:p-0 first:h-0",
"first:opacity-100"},
time: 300
)
}
>
<.cart_item result={result} myself={@myself} />
</li>
</ul>
<div
:if={@scan_results != []}
class="border-t border-zinc-200 dark:border-zinc-800 px-4 py-3"
>
<.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)
)}
</.button>
</div>
</div>
</aside>
</div> </div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".BarcodeScanner"> <script :type={Phoenix.LiveView.ColocatedHook} name=".BarcodeScanner">
@@ -75,6 +160,7 @@ defmodule MusicLibraryWeb.Components.BarcodeScanner do
async mounted() { async mounted() {
const detectedBarcodes = new Set([]); const detectedBarcodes = new Set([]);
const barcodeDetector = await barcodeReaderSetup(); const barcodeDetector = await barcodeReaderSetup();
this.detectedBarcodes = detectedBarcodes;
this.cameraPreview = this.el.querySelector("#camera-preview"); this.cameraPreview = this.el.querySelector("#camera-preview");
const constraints = { const constraints = {
audio: false, audio: false,
@@ -86,6 +172,12 @@ defmodule MusicLibraryWeb.Components.BarcodeScanner do
}, },
}, },
}; };
this.handleEvent("remove_barcode", ({ number }) => {
detectedBarcodes.delete(number);
});
this.handleEvent("clear_barcodes", () => {
detectedBarcodes.clear();
});
this.el.addEventListener("camera_request", () => { this.el.addEventListener("camera_request", () => {
navigator.mediaDevices navigator.mediaDevices
.getUserMedia(constraints) .getUserMedia(constraints)
@@ -318,78 +410,102 @@ defmodule MusicLibraryWeb.Components.BarcodeScanner do
""" """
end end
attr :scan_result, BarcodeScan.Result, required: true attr :result, BarcodeScan.Result, required: true
attr :myself, :any, required: true
defp scan_result(assigns) do defp cart_item(%{result: %{status: :not_found}} = assigns) do
~H""" ~H"""
<.barcode_not_found :if={@scan_result.status == :not_found} number={@scan_result.number} /> <.barcode_icon class="w-12 h-12 flex-none rounded-md p-2 bg-red-50 text-red-400 dark:bg-red-950" />
<.release <div class="min-w-0 flex-1">
:if={@scan_result.status != :not_found} <p class="truncate text-xs text-zinc-500 dark:text-zinc-400">
release={@scan_result.release} {@result.number}
record_id={@scan_result.record_id} </p>
status={@scan_result.status} <p class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-300">
/>
"""
end
attr :number, :string, required: true
defp barcode_not_found(assigns) do
~H"""
<div class="w-full bg-red-50 p-4 dark:bg-red-950">
<h1 class="text-sm/6 text-zinc-700 dark:text-zinc-400">
{gettext("Barcode not found")} {gettext("Barcode not found")}
</h1> </p>
<h2 class="mt-1 flex text-sm/5 font-semibold text-wrap text-zinc-700 sm:text-base dark:text-zinc-300"> <div class="mt-1 flex items-center gap-2">
{@number} <.status_badge status={:not_found} />
</h2> <button
type="button"
phx-click="remove_result"
phx-value-number={@result.number}
phx-target={@myself}
class="text-xs text-zinc-500 hover:text-red-600 dark:hover:text-red-400"
>
{gettext("Remove")}
</button>
</div>
</div> </div>
""" """
end end
attr :release, MusicBrainz.ReleaseSearchResult, required: true defp cart_item(assigns) do
attr :record_id, :string
attr :status, :atom, required: true, values: [:collected, :wishlisted, :new]
defp release(assigns) do
~H""" ~H"""
<div class="flex w-full items-center justify-between"> <img
<img class="w-12 h-12 rounded-md flex-none object-cover"
class="mr-4 w-16 flex-none rounded-lg md:w-20" alt={@result.release.release_group.title}
alt={@release.release_group.title} src={ReleaseGroupSearchResult.thumb_url(@result.release.release_group)}
src={ReleaseGroupSearchResult.thumb_url(@release.release_group)} onerror={"this.src = '" <> ~p"/images/cover-not-found.png" <> "';"}
onerror={"this.src = '" <> ~p"/images/cover-not-found.png" <> "';"} />
/> <div class="min-w-0 flex-1">
<div class="min-w-0 flex-auto"> <p class="truncate text-xs text-zinc-500 dark:text-zinc-400">
<h1 class="text-sm/6 text-zinc-700 dark:text-zinc-400"> {@result.release.artists}
{@release.artists} </p>
</h1> <p class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-300">
<h2 class="mt-1 flex text-sm/5 font-semibold text-wrap text-zinc-700 sm:text-base dark:text-zinc-300"> {@result.release.title}
{@release.title} </p>
</h2> <p class="truncate text-xs/5 text-zinc-500 dark:text-zinc-400">
<p class="mt-1 text-xs/5 text-zinc-500 dark:text-zinc-400"> {release_format_label(@result.release)} · {Records.Record.format_release_date(
{release_format_label(@release)} · {Records.Record.format_release_date(@release.date)} · {RecordComponents.type_label( @result.release.date
@release.release_group.type )} · {RecordComponents.type_label(@result.release.release_group.type)}
)} </p>
</p> <div class="mt-1 flex items-center gap-2">
<.status_badge status={@result.status} record_id={@result.record_id} />
<button
type="button"
phx-click="remove_result"
phx-value-number={@result.number}
phx-target={@myself}
class="text-xs text-zinc-500 hover:text-red-600 dark:hover:text-red-400"
>
{gettext("Remove")}
</button>
</div> </div>
<.badge :if={@status == :new}>
{gettext("New")}
</.badge>
<.link :if={@status == :wishlisted} navigate={~p"/wishlist/#{@record_id}"}>
<.badge color="warning">
{gettext("Wishlisted")}
</.badge>
</.link>
<.link :if={@status == :collected} navigate={~p"/collection/#{@record_id}"}>
<.badge color="success">
{gettext("Collected")}
</.badge>
</.link>
</div> </div>
""" """
end 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>{gettext("New")}</.badge>
"""
end
defp status_badge(%{status: :wishlisted} = assigns) do
~H"""
<.link navigate={~p"/wishlist/#{@record_id}"}>
<.badge color="warning">{gettext("Wishlisted")}</.badge>
</.link>
"""
end
defp status_badge(%{status: :collected} = assigns) do
~H"""
<.link navigate={~p"/collection/#{@record_id}"}>
<.badge color="success">{gettext("Collected")}</.badge>
</.link>
"""
end
defp status_badge(%{status: :not_found} = assigns) do
~H"""
<.badge color="danger">{gettext("Not found")}</.badge>
"""
end
@impl true @impl true
def handle_event("camera_allowed", _params, socket) do def handle_event("camera_allowed", _params, socket) do
{:noreply, assign(socket, camera: :allowed)} {:noreply, assign(socket, camera: :allowed)}
@@ -420,6 +536,26 @@ defmodule MusicLibraryWeb.Components.BarcodeScanner do
{:noreply, socket} {:noreply, socket}
end 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 def handle_event("import_releases", _params, socket) do
current_time = DateTime.utc_now() current_time = DateTime.utc_now()
scan_results = socket.assigns.scan_results scan_results = socket.assigns.scan_results
@@ -205,6 +205,7 @@ defmodule MusicLibraryWeb.CollectionLive.Index do
:if={@live_action == :barcode_scan} :if={@live_action == :barcode_scan}
id="barcode-scanner-modal" id="barcode-scanner-modal"
on_close={JS.patch(back_path(@record_list_params))} on_close={JS.patch(back_path(@record_list_params))}
width_class="md:max-w-4xl lg:max-w-5xl"
> >
<.live_component <.live_component
module={MusicLibraryWeb.Components.BarcodeScanner} module={MusicLibraryWeb.Components.BarcodeScanner}
+23 -5
View File
@@ -536,11 +536,6 @@ msgstr ""
msgid "Add new Record · Wishlist" msgid "Add new Record · Wishlist"
msgstr "" msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format
msgid "Add releases"
msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex #: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Adding..." msgid "Adding..."
@@ -1588,6 +1583,7 @@ msgid "Record set updated successfully"
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#: lib/music_library_web/live/record_set_live/index.ex #: lib/music_library_web/live/record_set_live/index.ex
#: lib/music_library_web/live/record_set_live/show.ex #: lib/music_library_web/live/record_set_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -2464,6 +2460,7 @@ msgid "A rule for this album already exists"
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "%{count} record" msgid "%{count} record"
msgid_plural "%{count} records" msgid_plural "%{count} records"
@@ -2476,6 +2473,7 @@ msgid "Add records from the search results to get started."
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cart" msgid "Cart"
msgstr "" msgstr ""
@@ -2486,6 +2484,7 @@ msgid "Choose which format to add"
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Clear all" msgid "Clear all"
msgstr "" msgstr ""
@@ -2498,11 +2497,13 @@ msgstr[0] ""
msgstr[1] "" msgstr[1] ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle cart" msgid "Toggle cart"
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Your cart is empty" msgid "Your cart is empty"
msgstr "" msgstr ""
@@ -2516,3 +2517,20 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "In cart" msgid "In cart"
msgstr "" msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format
msgid "Add %{count} release"
msgid_plural "Add %{count} releases"
msgstr[0] ""
msgstr[1] ""
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format
msgid "Not found"
msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format
msgid "Scan barcodes to add records."
msgstr ""
+23 -5
View File
@@ -536,11 +536,6 @@ msgstr ""
msgid "Add new Record · Wishlist" msgid "Add new Record · Wishlist"
msgstr "" msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format
msgid "Add releases"
msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex #: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Adding..." msgid "Adding..."
@@ -1588,6 +1583,7 @@ msgid "Record set updated successfully"
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#: lib/music_library_web/live/record_set_live/index.ex #: lib/music_library_web/live/record_set_live/index.ex
#: lib/music_library_web/live/record_set_live/show.ex #: lib/music_library_web/live/record_set_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -2464,6 +2460,7 @@ msgid "A rule for this album already exists"
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "%{count} record" msgid "%{count} record"
msgid_plural "%{count} records" msgid_plural "%{count} records"
@@ -2476,6 +2473,7 @@ msgid "Add records from the search results to get started."
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Cart" msgid "Cart"
msgstr "" msgstr ""
@@ -2486,6 +2484,7 @@ msgid "Choose which format to add"
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Clear all" msgid "Clear all"
msgstr "" msgstr ""
@@ -2498,11 +2497,13 @@ msgstr[0] ""
msgstr[1] "" msgstr[1] ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle cart" msgid "Toggle cart"
msgstr "" msgstr ""
#: lib/music_library_web/components/add_record.ex #: lib/music_library_web/components/add_record.ex
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Your cart is empty" msgid "Your cart is empty"
msgstr "" msgstr ""
@@ -2516,3 +2517,20 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "In cart" msgid "In cart"
msgstr "" msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Add %{count} release"
msgid_plural "Add %{count} releases"
msgstr[0] ""
msgstr[1] ""
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format
msgid "Not found"
msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#, elixir-autogen, elixir-format
msgid "Scan barcodes to add records."
msgstr ""
@@ -585,9 +585,9 @@ defmodule MusicLibraryWeb.CollectionLive.IndexTest do
conn conn
|> visit(~p"/collection/scan") |> visit(~p"/collection/scan")
|> trigger_hook("#barcode-scanner", "barcode_scanned", %{"number" => barcode}) |> trigger_hook("#barcode-scanner", "barcode_scanned", %{"number" => barcode})
|> assert_has("h2", "Marbles") |> assert_has("#cart-items", text: "Marbles")
|> assert_has("span", "New") |> assert_has("span", "New")
|> click_button("Add releases") |> click_button("Add 1 release")
[record] = MusicLibrary.Repo.all(MusicLibrary.Records.Record) [record] = MusicLibrary.Repo.all(MusicLibrary.Records.Record)