+ Three directions for a multi-record import flow. Static HTML, not wired up.
+
+
+
+
+
+
+
+
+
+
+
A — Side-by-side panel
+
+ Cart lives as a right column inside the existing import modal. Everything on one screen.
+ Strongest when the user is actively curating a batch.
+
+
+
+
+
+
+
+
Import records into your collection
+
+ Search MusicBrainz, add records to the cart, then import them all at once.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Steven Wilson
+
The Harmony Codex
+
2023 · Album
+
+
+
+ In cart · CD
+
+
+
+
+
+
+
+
Steven Wilson
+
Hand. Cannot. Erase.
+
2015 · Album
+
+
+
+
+
+
+
+
+
+
+
+
Steven Wilson
+
The Raven That Refused to Sing
+
2013 · Album
+
+
+
+ In cart · Vinyl
+
+
+
+
+
+
+
Steven Wilson
+
To The Bone
+
2017 · Album
+
+
+
+
+
+
+
+
+
Showing 4 of 38 results · scroll to load more
+
+
+
+
+
+
+
+
+
+
+
+
+
+
B — Bottom tray
+
+ Search keeps the full width (closest to today's layout). Cart is a collapsible tray
+ pinned to the bottom. Shown here expanded — collapsed state is a slim bar with a count.
+
+
+
+
+
+
Import records into your collection
+
+
+
+
+
+
+
+
+
+
+
+
Steven Wilson
+
The Harmony Codex
+
2023 · Album
+
+
+ In cart · CD
+
+
+
+
+
+
Steven Wilson
+
Hand. Cannot. Erase.
+
2015 · Album
+
+
+
+
+
+
+
+
+
+
Steven Wilson
+
The Raven That Refused to Sing
+
2013 · Album
+
+
+ In cart · Vinyl
+
+
+
+
+
+
+
+
+
+
+
Cart
+ 2 records
+
+
+
+
+
+
+
+
+
+
+
+
+
Steven Wilson
+
The Harmony Codex
+
+ CD
+
+
+
+
+
+
+
+
Steven Wilson
+
The Raven That Refused to Sing
+
+ Vinyl
+
+
+
+
+
+
+
+
+
+
+
Collapsed tray state
+
+
(search area)
+
+
+
+
Cart
+ 2 records
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
C — Inline state + floating cart
+
+ Smallest footprint: results list looks almost like today, added items flip to an "In cart"
+ chip. A floating badge in the corner opens a popover with the cart and import button.
+
+
+
+
+
+
Import records into your collection
+
+
+
+
+
+
+
+
+
+
+
+
Steven Wilson
+
The Harmony Codex
+
2023 · Album
+
+
+
+ In cart · CD
+
+
+
+
+
+
Steven Wilson
+
Hand. Cannot. Erase.
+
2015 · Album
+
+
+
+
+
+
+
Steven Wilson
+
The Raven That Refused to Sing
+
2013 · Album
+
+
+
+ In cart · Vinyl
+
+
+
+
+
+
+
+
+
Cart (2)
+
+
+
+
+
+
+
Steven Wilson
+
The Harmony Codex
+
+ CD
+
+
+
+
+
+
+
+
Steven Wilson
+
The Raven That Refused to Sing
+
+ Vinyl
+
+
+
+
+
+
+
+
+
+
+
+
Floating trigger (popover closed)
+
+
(search + results)
+
+
+
+
+
+
Quick tradeoffs
+
+
A (side-by-side): best discoverability and clearest mental model, but modal needs to grow (cart column steals width from search). Cramped on narrower viewports.
+
B (bottom tray): closest to the current layout. Cheap to build on top of today's component. Tray is always reachable but eats vertical room when expanded.
+
C (inline + floating): smallest visual change; cart hidden behind a badge click. Adds a click between "I want to import" and the import button, but list view feels unchanged.
+
+
+
+
+
diff --git a/backlog/ml-143/plan.md b/backlog/ml-143/plan.md
new file mode 100644
index 00000000..10f22099
--- /dev/null
+++ b/backlog/ml-143/plan.md
@@ -0,0 +1,205 @@
+# Cart-style multi-record import
+
+## Context
+
+Today the "Add Record" modal (`lib/music_library_web/components/add_record.ex`) imports one record at a time: click the `+` icon on a MusicBrainz result, pick a format from the dropdown, the LiveView calls `Records.import_from_musicbrainz_release_group/2` synchronously, toasts, and `push_navigate`s to the new record. Importing several records in a sitting means repeating that loop.
+
+This change turns the modal into a shopping-cart flow. The user stages multiple `{release_group, format}` items in an ephemeral cart, then hits **Import N records** once. Small batches run sync in the modal (spinner in the button); larger batches enqueue Oban jobs and close the modal with a "Importing N in background" toast — mirroring the existing barcode-scan threshold logic.
+
+Goal: reduce repetitive clicking when adding multiple records from the same search session, reusing the project's existing async-import and LiveComponent patterns.
+
+## Scope
+
+UI direction (already agreed):
+- Mockup **B** (bottom tray) on small viewports (default / `sm:`).
+- Mockup **A** (side-by-side: search left, cart right) on `md:` and up — the import modal widens to fit.
+- Empty cart state shown in-place (not hidden).
+- Format picker keeps the existing `dropdown` + `dropdown_link` UI; the click adds to the cart rather than importing.
+
+Batch size behaviour (mirrors `BarcodeScan`):
+- **1 item**: sync via `start_async`. Spinner in the "Import 1 record" button; modal stays open during the call; on success parent does `push_navigate` to the new record + toast; on error the modal stays open and shows an error toast.
+- **2+ items**: enqueue one `ImportFromMusicbrainzReleaseGroup` Oban job per cart item via `Oban.insert_all/1`; parent toasts "Importing N records in the background..." and `push_patch`es back to the base index (modal closes).
+
+Duplicate cart entries: deduped by `{release_group_id, format}`. Adding the same pair twice is a no-op; adding the same release group with a different format creates a second cart row (users can own multiple formats of a release — consistent with the existing "allow duplicate imports" stance).
+
+Out of scope:
+- Persisted cart (explicitly ephemeral).
+- Stats page `handle_event("import", ...)` at `lib/music_library_web/live/stats_live/index.ex:113` — different flow (`import_from_musicbrainz_release`, singular), untouched.
+- Barcode scanner flow — already batches; no change.
+
+## Implementation
+
+### 1. New Oban worker — `MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup`
+
+New file: `lib/music_library/worker/import_from_musicbrainz_release_group.ex`.
+
+- `use Oban.Worker, queue: :music_brainz, max_attempts: 3`
+- Args: `%{"release_group_id" => string, "format" => string, "purchased_at" => iso8601_string}`
+- `perform/1` parses the format (`String.to_existing_atom/1` after whitelisting against `Records.Record.formats()`), parses the datetime, and delegates to `Records.import_from_musicbrainz_release_group/2`.
+- Returns `:ok` on success, `{:error, reason}` on failure (Oban retries).
+
+Mirror the structure of `lib/music_library/worker/import_from_musicbrainz_release.ex` exactly.
+
+### 2. `structured_modal` gains a width attr
+
+`lib/music_library_web/components/core_components.ex:154-173`. Currently hardcodes `md:max-w-3xl`.
+
+- Add `attr :width_class, :string, default: "md:max-w-3xl"`.
+- Interpolate at the tail of the class string: `"mx-auto mt-8 max-w-sm sm:min-w-2xl #{@width_class}"`. Tailwind last-wins handles override.
+- Name is `width_class` (not `max_width`) to make it clear the caller supplies a Tailwind utility and that it's appended, so they remember to include responsive prefixes.
+- Backwards-compatible: the 13 existing callers keep today's width unchanged.
+
+### 3. `AddRecord` LiveComponent — cart state and responsive layout
+
+`lib/music_library_web/components/add_record.ex`.
+
+#### Assigns (added to `mount/1`)
+- `cart :: [%{cart_item_id, release_group_id, title, artists, release_date, thumb_url, format}]` — ordered list, newest first. `cart_item_id` via `System.unique_integer([:positive])`.
+- `cart_pairs :: MapSet.t({binary, atom})` — set of `{release_group_id, format}` for O(1) "already in cart" and dedup checks.
+- `cart_expanded? :: boolean` — mobile tray collapse; defaults to `true`. Always-expanded on `md:` via Tailwind `md:!block`.
+- `importing? :: boolean` — single-item sync spinner flag.
+
+#### Events (all `phx-target={@myself}`)
+- `"add_to_cart"` — payload `%{"id" => rg_id, "format" => format_str, "title" => ..., "artists" => ..., "release_date" => ..., "thumb_url" => ...}`. Display fields come from `JS.push` values on the dropdown link so we don't need a parallel `release_groups_by_id` map. Validates `format_str` against `Records.Record.formats()`. If `{rg_id, format_atom}` already in `cart_pairs`, no-op.
+- `"remove_from_cart"` — `%{"cart_item_id" => id}`. Updates `cart` + `cart_pairs`.
+- `"change_format"` — `%{"cart_item_id" => id, "format" => format_str}`. Whitelists format. No-op if the new `{rg_id, format_atom}` would collide with another cart row.
+- `"clear_cart"` — empties both assigns.
+- `"toggle_cart"` — flips `cart_expanded?` (mobile only; no-op visually on `md:+`).
+- `"import_cart"` — dispatches:
+ - `[]`: silently ignored (button is disabled anyway).
+ - `[single]`: `assign(:importing?, true)` + `start_async(:import_cart, fn -> Records.import_from_musicbrainz_release_group(rg_id, format: format, purchased_at: now) end)`.
+ - `[_, _ | _]`: builds a list of `ImportFromMusicbrainzReleaseGroup.new/1` changesets, single `Oban.insert_all/1` call (atomic — no partial enqueue on failure), then `notify_parent({:imported_async, count})`.
+
+#### `handle_async(:import_cart, ...)`
+- `{:ok, {:ok, record}}` → `notify_parent({:imported_single, record})`. Parent handles navigation + toast + modal close; no local reset needed since the component is about to unmount.
+- `{:ok, {:error, reason}}` → `put_toast!(:error, gettext("Error importing record") <> ": " <> ErrorMessages.friendly_message(reason))`, `assign(:importing?, false)`. Modal stays open so the user can adjust the cart.
+- `{:exit, reason}` → `Logger.warning(inspect(reason))` + generic error toast, reset `importing?`.
+
+#### `notify_parent/1`
+Private helper: `defp notify_parent(msg), do: send(self(), {__MODULE__, msg})`. Matches the convention used in `record_form.ex:646`, `online_store_template_live/form.ex:140`, etc.
+
+#### Render structure
+Root becomes a responsive grid:
+
+```
+
+
+
+```
+
+Results column keeps the existing search input + stream + `max-h-125 overflow-y-auto` + `phx-viewport-bottom` load-more. Each result row gets an "In cart" chip when `MapSet.member?(@cart_pairs, {rg_id, any_format})` — a cheap helper `in_cart?/2`. The `+ icon → dropdown` loop unchanged; only the `phx-click` target becomes `JS.push("add_to_cart", value: %{id:, format:, title:, artists:, release_date:, thumb_url:}, target: "##{@id}")` with `page_loading: false` removed (cart-add is instant).
+
+Cart column:
+- Header: cart count, "Clear all" link (both gettext'd), mobile chevron (`phx-click="toggle_cart"`) hidden on `md:+`.
+- Body wrapped in `
` so mobile collapse doesn't leak past `md:` (`md:!block` is the important-override).
+ - Empty state when `@cart == []`: music icon + short gettext'd hint.
+ - Otherwise the list: thumbnail, artists/title, per-row format `