Add ML-143: Cart-style multi record import
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Add Record — Cart UI Mockups</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = { darkMode: "class" };
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
||||
.mockup-frame { box-shadow: 0 10px 30px rgba(0,0,0,0.18); }
|
||||
/* simple ring to mimic modal shell */
|
||||
.modal-shell { border: 1px solid rgb(228 228 231 / 1); }
|
||||
.dark .modal-shell { border-color: rgb(63 63 70 / 1); }
|
||||
/* hero-plus like icon via unicode placeholders; SVGs inline below */
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100">
|
||||
<div class="mx-auto max-w-6xl px-6 py-10">
|
||||
<div class="flex items-center justify-between mb-10">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Add Record — Cart UI Mockups</h1>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Three directions for a multi-record import flow. Static HTML, not wired up.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick="document.documentElement.classList.toggle('dark')"
|
||||
class="rounded-md border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Toggle dark
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- =========================================================== -->
|
||||
<!-- MOCKUP A — Side-by-side panel -->
|
||||
<!-- =========================================================== -->
|
||||
<section class="mb-16">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg font-semibold">A — Side-by-side panel</h2>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Cart lives as a right column inside the existing import modal. Everything on one screen.
|
||||
Strongest when the user is actively curating a batch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mockup-frame modal-shell rounded-2xl overflow-hidden bg-white dark:bg-zinc-900">
|
||||
<!-- modal header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">Import records into your collection</h3>
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
Search MusicBrainz, add records to the cart, then import them all at once.
|
||||
</p>
|
||||
</div>
|
||||
<button class="rounded-md p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800" aria-label="Close">
|
||||
<svg class="size-5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- modal body -->
|
||||
<div class="grid grid-cols-5">
|
||||
<!-- search column -->
|
||||
<div class="col-span-3 p-6 border-r border-zinc-200 dark:border-zinc-800">
|
||||
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1" for="mb_query">
|
||||
Search for a record
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="mb_query"
|
||||
value="steven wilson"
|
||||
class="block w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 py-2 pl-3 pr-10 text-sm focus:border-zinc-900 dark:focus:border-zinc-100 focus:outline-none"
|
||||
/>
|
||||
<svg class="absolute right-3 top-2.5 size-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-4.35-4.35M10.5 18a7.5 7.5 0 100-15 7.5 7.5 0 000 15z"/></svg>
|
||||
</div>
|
||||
|
||||
<ul role="list" class="mt-5 divide-y divide-zinc-100 dark:divide-slate-300/30 max-h-[22rem] overflow-y-auto pr-1">
|
||||
<!-- result row (in cart) -->
|
||||
<li class="flex items-center gap-4 py-4 bg-emerald-50/40 dark:bg-emerald-500/5 rounded-md px-2">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/38ad9108-8f7b-4d0e-8efe-6015f3c3c921/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">The Harmony Codex</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2023 · Album</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 text-xs font-medium px-2 py-0.5">
|
||||
<svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>
|
||||
In cart · CD
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<!-- result row with format dropdown -->
|
||||
<li class="flex items-center gap-4 py-4 px-2 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 rounded-md">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/b6cf90a6-5ca4-45c5-92f9-3c729e81ee95/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">Hand. Cannot. Erase.</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2015 · Album</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<select class="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 text-xs px-2 py-1">
|
||||
<option>CD</option>
|
||||
<option>Vinyl</option>
|
||||
<option>Cassette</option>
|
||||
<option>Digital</option>
|
||||
</select>
|
||||
<button class="rounded-md border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 p-1.5" title="Add to cart">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- result row (in cart, vinyl) -->
|
||||
<li class="flex items-center gap-4 py-4 bg-emerald-50/40 dark:bg-emerald-500/5 rounded-md px-2">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/3f57e8fe-4b39-4f50-a1eb-5d7b0dc8a4a2/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">The Raven That Refused to Sing</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2013 · Album</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 text-xs font-medium px-2 py-0.5">
|
||||
<svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>
|
||||
In cart · Vinyl
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-4 py-4 px-2 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 rounded-md">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/5a8ab40e-8ccb-4d46-9d1e-7b9f60b76fa2/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">To The Bone</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2017 · Album</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<select class="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 text-xs px-2 py-1">
|
||||
<option>CD</option>
|
||||
<option selected>Vinyl</option>
|
||||
<option>Cassette</option>
|
||||
<option>Digital</option>
|
||||
</select>
|
||||
<button class="rounded-md border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 p-1.5">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="mt-3 text-xs text-zinc-500 dark:text-zinc-400">Showing 4 of 38 results · scroll to load more</p>
|
||||
</div>
|
||||
|
||||
<!-- cart column -->
|
||||
<aside class="col-span-2 bg-zinc-50 dark:bg-zinc-900/60 flex flex-col">
|
||||
<div class="px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold">Import cart</p>
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400">2 records ready to import</p>
|
||||
</div>
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100">Clear all</button>
|
||||
</div>
|
||||
|
||||
<ul class="flex-1 overflow-y-auto divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
<li class="flex gap-3 px-5 py-3">
|
||||
<img class="w-12 h-12 rounded-md object-cover" src="https://coverartarchive.org/release-group/38ad9108-8f7b-4d0e-8efe-6015f3c3c921/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-medium">The Harmony Codex</p>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<select class="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 text-xs px-1.5 py-0.5">
|
||||
<option selected>CD</option><option>Vinyl</option><option>Cassette</option><option>Digital</option>
|
||||
</select>
|
||||
<button class="text-xs text-zinc-500 hover:text-red-600 dark:hover:text-red-400">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3 px-5 py-3">
|
||||
<img class="w-12 h-12 rounded-md object-cover" src="https://coverartarchive.org/release-group/3f57e8fe-4b39-4f50-a1eb-5d7b0dc8a4a2/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-medium">The Raven That Refused to Sing</p>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<select class="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 text-xs px-1.5 py-0.5">
|
||||
<option>CD</option><option selected>Vinyl</option><option>Cassette</option><option>Digital</option>
|
||||
</select>
|
||||
<button class="text-xs text-zinc-500 hover:text-red-600 dark:hover:text-red-400">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="border-t border-zinc-200 dark:border-zinc-800 px-5 py-4 space-y-2">
|
||||
<button class="w-full rounded-lg bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-sm font-semibold py-2 hover:opacity-90">
|
||||
Import 2 records
|
||||
</button>
|
||||
<p class="text-[11px] text-zinc-500 dark:text-zinc-400 text-center">
|
||||
Each import runs as a background job, same pipeline as barcode scan batches.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =========================================================== -->
|
||||
<!-- MOCKUP B — Bottom tray -->
|
||||
<!-- =========================================================== -->
|
||||
<section class="mb-16">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg font-semibold">B — Bottom tray</h2>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mockup-frame modal-shell rounded-2xl overflow-hidden bg-white dark:bg-zinc-900">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<h3 class="text-base font-semibold">Import records into your collection</h3>
|
||||
<button class="rounded-md p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800" aria-label="Close">
|
||||
<svg class="size-5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">Search for a record</label>
|
||||
<input
|
||||
value="steven wilson"
|
||||
class="block w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 py-2 px-3 text-sm"
|
||||
/>
|
||||
|
||||
<ul role="list" class="mt-5 divide-y divide-zinc-100 dark:divide-slate-300/30 max-h-[20rem] overflow-y-auto">
|
||||
<li class="flex items-center gap-4 py-4 px-2 bg-emerald-50/40 dark:bg-emerald-500/5 rounded-md">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/38ad9108-8f7b-4d0e-8efe-6015f3c3c921/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">The Harmony Codex</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2023 · Album</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 text-xs font-medium px-2 py-0.5">
|
||||
In cart · CD
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-4 py-4 px-2 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 rounded-md">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/b6cf90a6-5ca4-45c5-92f9-3c729e81ee95/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">Hand. Cannot. Erase.</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2015 · Album</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<select class="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 text-xs px-2 py-1">
|
||||
<option>CD</option><option>Vinyl</option><option>Cassette</option><option>Digital</option>
|
||||
</select>
|
||||
<button class="rounded-md border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 p-1.5">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center gap-4 py-4 px-2 bg-emerald-50/40 dark:bg-emerald-500/5 rounded-md">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/3f57e8fe-4b39-4f50-a1eb-5d7b0dc8a4a2/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">The Raven That Refused to Sing</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2013 · Album</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 text-xs font-medium px-2 py-0.5">
|
||||
In cart · Vinyl
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- expanded tray -->
|
||||
<div class="border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/60">
|
||||
<div class="px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="size-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<p class="text-sm font-semibold">Cart</p>
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400">2 records</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100">Clear</button>
|
||||
<button class="rounded-lg bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-sm font-semibold px-4 py-1.5 hover:opacity-90">
|
||||
Import 2 records
|
||||
</button>
|
||||
<button class="rounded-md p-1 hover:bg-zinc-200 dark:hover:bg-zinc-800" title="Collapse">
|
||||
<svg class="size-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 pb-4">
|
||||
<ul class="flex gap-3 overflow-x-auto pb-1">
|
||||
<li class="flex-none w-56 rounded-lg bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 p-2 flex gap-2">
|
||||
<img class="w-10 h-10 rounded-md object-cover" src="https://coverartarchive.org/release-group/38ad9108-8f7b-4d0e-8efe-6015f3c3c921/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[11px] text-zinc-500">Steven Wilson</p>
|
||||
<p class="truncate text-xs font-medium">The Harmony Codex</p>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<span class="text-[10px] rounded bg-zinc-200 dark:bg-zinc-800 px-1.5 py-0.5">CD</span>
|
||||
<button class="text-[11px] text-zinc-500 hover:text-red-600 dark:hover:text-red-400">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex-none w-56 rounded-lg bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 p-2 flex gap-2">
|
||||
<img class="w-10 h-10 rounded-md object-cover" src="https://coverartarchive.org/release-group/3f57e8fe-4b39-4f50-a1eb-5d7b0dc8a4a2/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[11px] text-zinc-500">Steven Wilson</p>
|
||||
<p class="truncate text-xs font-medium">The Raven That Refused to Sing</p>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<span class="text-[10px] rounded bg-zinc-200 dark:bg-zinc-800 px-1.5 py-0.5">Vinyl</span>
|
||||
<button class="text-[11px] text-zinc-500 hover:text-red-600 dark:hover:text-red-400">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- collapsed state -->
|
||||
<p class="mt-4 mb-2 text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Collapsed tray state</p>
|
||||
<div class="mockup-frame modal-shell rounded-2xl overflow-hidden bg-white dark:bg-zinc-900">
|
||||
<div class="px-6 py-8 text-center text-zinc-400 text-sm">(search area)</div>
|
||||
<div class="border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/60 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="size-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17"/></svg>
|
||||
<p class="text-sm font-semibold">Cart</p>
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400">2 records</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="rounded-lg bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-sm font-semibold px-4 py-1.5 hover:opacity-90">Import 2</button>
|
||||
<button class="rounded-md p-1 hover:bg-zinc-200 dark:hover:bg-zinc-800" title="Expand">
|
||||
<svg class="size-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =========================================================== -->
|
||||
<!-- MOCKUP C — Inline state + floating cart popover -->
|
||||
<!-- =========================================================== -->
|
||||
<section class="mb-16">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg font-semibold">C — Inline state + floating cart</h2>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mockup-frame modal-shell rounded-2xl overflow-hidden bg-white dark:bg-zinc-900 relative">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<h3 class="text-base font-semibold">Import records into your collection</h3>
|
||||
<button class="rounded-md p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800" aria-label="Close">
|
||||
<svg class="size-5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">Search for a record</label>
|
||||
<input
|
||||
value="steven wilson"
|
||||
class="block w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 py-2 px-3 text-sm"
|
||||
/>
|
||||
|
||||
<ul role="list" class="mt-5 divide-y divide-zinc-100 dark:divide-slate-300/30 max-h-[22rem] overflow-y-auto">
|
||||
<li class="flex items-center gap-4 py-4 px-2">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/38ad9108-8f7b-4d0e-8efe-6015f3c3c921/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">The Harmony Codex</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2023 · Album</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 text-xs font-medium px-2 py-0.5">
|
||||
<svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>
|
||||
In cart · CD
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-4 py-4 px-2">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/b6cf90a6-5ca4-45c5-92f9-3c729e81ee95/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">Hand. Cannot. Erase.</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2015 · Album</p>
|
||||
</div>
|
||||
<button class="inline-flex items-center gap-1 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 text-xs px-2 py-1 hover:bg-zinc-50 dark:hover:bg-zinc-800">
|
||||
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Add…
|
||||
</button>
|
||||
</li>
|
||||
<li class="flex items-center gap-4 py-4 px-2">
|
||||
<img class="w-16 h-16 rounded-lg object-cover" src="https://coverartarchive.org/release-group/3f57e8fe-4b39-4f50-a1eb-5d7b0dc8a4a2/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">Steven Wilson</p>
|
||||
<p class="truncate text-sm font-semibold">The Raven That Refused to Sing</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">2013 · Album</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 text-xs font-medium px-2 py-0.5">
|
||||
<svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>
|
||||
In cart · Vinyl
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- floating cart popover, shown open -->
|
||||
<div class="absolute right-6 bottom-6 w-80 rounded-xl bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 shadow-xl">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
|
||||
<p class="text-sm font-semibold">Cart (2)</p>
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100">Clear</button>
|
||||
</div>
|
||||
<ul class="divide-y divide-zinc-200 dark:divide-zinc-800 max-h-56 overflow-y-auto">
|
||||
<li class="flex gap-3 px-4 py-3">
|
||||
<img class="w-10 h-10 rounded-md object-cover" src="https://coverartarchive.org/release-group/38ad9108-8f7b-4d0e-8efe-6015f3c3c921/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[11px] text-zinc-500">Steven Wilson</p>
|
||||
<p class="truncate text-xs font-medium">The Harmony Codex</p>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span class="text-[10px] rounded bg-zinc-200 dark:bg-zinc-800 px-1.5 py-0.5">CD</span>
|
||||
<button class="text-[11px] text-zinc-500 hover:text-red-600 dark:hover:text-red-400">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3 px-4 py-3">
|
||||
<img class="w-10 h-10 rounded-md object-cover" src="https://coverartarchive.org/release-group/3f57e8fe-4b39-4f50-a1eb-5d7b0dc8a4a2/front-250.jpg" alt=""/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[11px] text-zinc-500">Steven Wilson</p>
|
||||
<p class="truncate text-xs font-medium">The Raven That Refused to Sing</p>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span class="text-[10px] rounded bg-zinc-200 dark:bg-zinc-800 px-1.5 py-0.5">Vinyl</span>
|
||||
<button class="text-[11px] text-zinc-500 hover:text-red-600 dark:hover:text-red-400">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="p-3 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<button class="w-full rounded-lg bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-sm font-semibold py-2 hover:opacity-90">
|
||||
Import 2 records
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 mb-2 text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Floating trigger (popover closed)</p>
|
||||
<div class="mockup-frame modal-shell rounded-2xl overflow-hidden bg-white dark:bg-zinc-900 relative">
|
||||
<div class="px-6 py-8 text-center text-zinc-400 text-sm">(search + results)</div>
|
||||
<button class="absolute right-6 bottom-6 inline-flex items-center gap-2 rounded-full bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-sm font-semibold pl-3 pr-2 py-2 shadow-xl hover:opacity-90">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17"/></svg>
|
||||
Cart
|
||||
<span class="inline-flex items-center justify-center rounded-full bg-emerald-500 text-white text-xs font-bold w-5 h-5">2</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="text-sm text-zinc-600 dark:text-zinc-400 leading-relaxed">
|
||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Quick tradeoffs</h2>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li><strong>A (side-by-side)</strong>: best discoverability and clearest mental model, but modal needs to grow (cart column steals width from search). Cramped on narrower viewports.</li>
|
||||
<li><strong>B (bottom tray)</strong>: closest to the current layout. Cheap to build on top of today's component. Tray is always reachable but eats vertical room when expanded.</li>
|
||||
<li><strong>C (inline + floating)</strong>: 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.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
<div class="grid grid-cols-1 md:grid-cols-5">
|
||||
<section class="md:col-span-3 ..."> <!-- search + results (today's UI) -->
|
||||
<aside class="md:col-span-2 border-t md:border-t-0 md:border-l ..."> <!-- cart -->
|
||||
</div>
|
||||
```
|
||||
|
||||
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 `<div class={["md:!block", not @cart_expanded? and "hidden"]}>` 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 `<select>` (posts `"change_format"`), "Remove" text button.
|
||||
- Scroll container `md:max-h-[calc(100vh-12rem)] overflow-y-auto` so a long cart doesn't push the footer off-screen.
|
||||
- Footer: "Import N records" button (`phx-click="import_cart"`, `ngettext` for the label). Disabled when cart empty OR `@importing?`. Icon swap: `hero-arrow-path animate-spin` when `@importing?`, otherwise `hero-plus`. Button hidden when cart empty — empty-state has its own guidance.
|
||||
|
||||
Dropdown placement on `md:col-span-3` may need `placement="left-start"` at `md:` to avoid right-edge clipping in the narrower results column — verify in the browser and adjust if needed.
|
||||
|
||||
### 4. Parent LiveViews — wire the cart messages
|
||||
|
||||
`lib/music_library_web/live/collection_live/index.ex` and `lib/music_library_web/live/wishlist_live/index.ex`.
|
||||
|
||||
- Remove the `handle_event("import", ...)` clause and its call to `IndexActions.handle_import/3` (not dropping `IndexActions.handle_import/3` itself yet — see step 6).
|
||||
- Add:
|
||||
```elixir
|
||||
def handle_info({AddRecord, {:imported_single, record}}, socket),
|
||||
do: IndexActions.handle_cart_imported_single(socket, record)
|
||||
|
||||
def handle_info({AddRecord, {:imported_async, count}}, socket),
|
||||
do: IndexActions.handle_cart_imported_async(socket, count)
|
||||
```
|
||||
- Update the `structured_modal` call for `live_action == :import` to pass `width_class="md:max-w-4xl lg:max-w-5xl"`. The other `structured_modal` calls (edit, barcode scan) keep the default width.
|
||||
- Pass `on_close={nil}` (or a no-op) on the import modal while `@importing?`. Simplest: pass the close handler only when not importing — but `@importing?` is a component assign, not exposed to the parent. Alternative: the component's `toggle_cart` / close button is a no-op while importing; the outer Fluxon modal close still works. Accept this: if the user closes the modal mid-import, the `handle_async` callback lands on a detached component and is silently dropped by Phoenix — no user-visible bug. Add a short comment in the component pointing this out.
|
||||
|
||||
### 5. `IndexActions` — new shared helpers
|
||||
|
||||
`lib/music_library_web/live_helpers/index_actions.ex`.
|
||||
|
||||
- `handle_cart_imported_single(socket, record)`:
|
||||
```elixir
|
||||
config = socket.assigns.index_config
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_toast(:info, config.import_success_toast)
|
||||
|> push_navigate(to: config.record_path_fn.(record.id))}
|
||||
```
|
||||
Reuses `config.import_success_toast` and `config.record_path_fn` that already exist.
|
||||
- `handle_cart_imported_async(socket, count)`:
|
||||
```elixir
|
||||
config = socket.assigns.index_config
|
||||
|
||||
msg =
|
||||
ngettext(
|
||||
"Importing %{count} record in the background...",
|
||||
"Importing %{count} records in the background...",
|
||||
count,
|
||||
count: count
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_toast(:info, msg)
|
||||
|> push_patch(to: config.base_index_path)}
|
||||
```
|
||||
The `push_patch` back to `base_index_path` closes the modal because `@live_action` resets.
|
||||
|
||||
### 6. Remove dead code
|
||||
|
||||
Once steps 3–5 are in place:
|
||||
- `IndexActions.handle_import/3` is no longer called (verified earlier: only collection + wishlist called it; stats does not). Remove it.
|
||||
- The old `handle_event("import", ...)` in both collection and wishlist LiveViews is gone (step 4).
|
||||
|
||||
### 7. Gettext
|
||||
|
||||
After implementation, run `mix gettext.extract` and `mix gettext.merge priv/gettext` to regenerate `.pot` / `.po` files. New strings to cover:
|
||||
- "In cart"
|
||||
- "Import %{count} record" / "Import %{count} records" (ngettext)
|
||||
- "Your cart is empty"
|
||||
- "Clear all"
|
||||
- "Remove"
|
||||
- "Importing %{count} record in the background..." / plural (matches barcode scan wording)
|
||||
- "Error importing record"
|
||||
|
||||
Some (e.g. the background toast) may already exist from the barcode flow — reuse identical strings so translations don't duplicate.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `lib/music_library_web/components/add_record.ex` — full rewrite of the render plus cart state/handlers (biggest change).
|
||||
- `lib/music_library_web/live_helpers/index_actions.ex` — add `handle_cart_imported_single/2` and `handle_cart_imported_async/2`; remove `handle_import/3`.
|
||||
- `lib/music_library_web/live/collection_live/index.ex` — remove old `handle_event("import", ...)`, add `handle_info/2` clauses, widen import modal.
|
||||
- `lib/music_library_web/live/wishlist_live/index.ex` — same changes.
|
||||
- `lib/music_library_web/components/core_components.ex` — add `width_class` attr to `structured_modal/1`.
|
||||
- `lib/music_library/worker/import_from_musicbrainz_release_group.ex` — new file.
|
||||
- `test/music_library_web/components/add_record_test.exs` — new or updated component tests (today's import flow tests likely live in the live-view tests; see below).
|
||||
- `test/music_library_web/live/collection_live/index_test.exs` — update import-flow tests to go via cart (add-to-cart → import → assert).
|
||||
- `test/music_library_web/live/wishlist_live/index_test.exs` — same.
|
||||
- `test/music_library/worker/import_from_musicbrainz_release_group_test.exs` — new worker test modelled on `fetch_artist_info_test.exs` / `artist_refresh_wikipedia_data_test.exs` (existing worker test shape in the repo).
|
||||
- `priv/gettext/*` — regenerated by `mix gettext.extract --merge`.
|
||||
|
||||
No migrations, no Oban config changes (the `:music_brainz` queue already exists), no JS hooks.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `mise run dev:precommit` — format, Credo, Sobelow, ExSlop, tests. Must pass.
|
||||
2. Manual browser checks with the dev server running:
|
||||
- **Small viewport** (narrow window): open Collection → "Add record" → search → add 3 items with different formats → cart shows as bottom tray; collapse/expand chevron works; "Import 3 records" enqueues (assert via Oban web `/dev/oban` or toast).
|
||||
- **`md:+` viewport**: same flow; cart sits as right column; dropdown doesn't clip.
|
||||
- **Single-item sync path**: add 1 item → click "Import 1 record" → button shows spinner → modal closes → lands on the new record's detail page → toast visible.
|
||||
- **Error path**: stub MusicBrainz to fail (or pick an invalid release) → error toast appears in the modal, modal stays open, cart preserved, `importing?` cleared.
|
||||
- **Dedup**: add same `{rg, format}` twice → no duplicate cart row. Add same `rg` with two different formats → two cart rows.
|
||||
- **Wishlist parity**: repeat the happy path from Wishlist index.
|
||||
3. `mix test test/music_library/worker/import_from_musicbrainz_release_group_test.exs` — worker unit coverage.
|
||||
4. `mix test test/music_library_web/live/collection_live/index_test.exs test/music_library_web/live/wishlist_live/index_test.exs` — updated feature tests for the cart flow, including `assert_enqueued` for 2+ items and `refute_enqueued` for the sync 1-item path.
|
||||
5. `grep -R "handle_event(\"import\"" lib/ | grep -v stats_live` should return nothing, confirming removal.
|
||||
|
||||
## Open items deferred to implementation
|
||||
|
||||
- Exact Tailwind classes for the cart column (min/max heights, padding) — start with the defaults above, tweak in the browser.
|
||||
- Dropdown `placement` on `md:` if right-edge clipping appears.
|
||||
- Whether the empty-state deserves an illustration/icon or just text — copy-level only.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
id: ML-143
|
||||
title: Cart-style multi-record import in Add Record modal
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-20 10:00'
|
||||
updated_date: '2026-04-20 10:01'
|
||||
labels:
|
||||
- ui
|
||||
- liveview
|
||||
- import
|
||||
dependencies: []
|
||||
references:
|
||||
- backlog/ml-143/plan.md
|
||||
- backlog/ml-143/mockups.html
|
||||
documentation:
|
||||
- backlog/ml-143/plan.md
|
||||
- backlog/ml-143/mockups.html
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace the one-record-at-a-time import in the Add Record modal with a shopping-cart staging flow. Users build an ephemeral cart of `{release_group, format}` items from MusicBrainz search results, then import them all at once.
|
||||
|
||||
Batch-size behaviour mirrors the existing `BarcodeScan` split:
|
||||
- 1 item → sync `start_async` with a spinner in the "Import 1 record" button; on success the modal closes and navigates to the new record.
|
||||
- 2+ items → one Oban job per item via a new `ImportFromMusicbrainzReleaseGroup` worker; modal closes immediately and toasts "Importing N records in the background..."
|
||||
|
||||
Layout: mockup B (bottom tray) as baseline, mockup A (search left, cart right) on `md:` and up, with the import modal widened for the side-by-side view. Empty-cart state is shown in place.
|
||||
|
||||
Full technical plan and visual mockups are in the task folder — see References.
|
||||
|
||||
Affects both `CollectionLive.Index` and `WishlistLive.Index`. Barcode scanner flow and `StatsLive` single-record import are untouched.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 From the Collection or Wishlist index, clicking a format in the result dropdown adds a `{release_group, format}` item to the cart instead of importing immediately
|
||||
- [ ] #2 The cart renders as a collapsible bottom tray on small viewports and as a right-hand panel on `md:` and up
|
||||
- [ ] #3 The import modal widens on `md:` and up to fit the side-by-side layout
|
||||
- [ ] #4 Result rows show an 'In cart' chip when the release group is already staged
|
||||
- [ ] #5 Adding the same `{release_group_id, format}` pair twice is a no-op; the same release group with a different format creates a second cart row
|
||||
- [ ] #6 Empty cart shows a visible placeholder with guidance to add records
|
||||
- [ ] #7 Clicking 'Import 1 record' runs synchronously: button shows a spinner, modal stays open until done, then closes and navigates to the new record with an info toast
|
||||
- [ ] #8 Clicking 'Import N records' (N>=2) enqueues one Oban job per item, toasts 'Importing N records in the background...', and closes the modal
|
||||
- [ ] #9 A synchronous import failure leaves the modal open, preserves the cart, clears the spinner, and surfaces a friendly error toast
|
||||
- [ ] #10 Cart state does not persist across modal close/reopen (ephemeral)
|
||||
- [ ] #11 A new `MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup` Oban worker delegates to `Records.import_from_musicbrainz_release_group/2` with retry semantics matching the existing `ImportFromMusicbrainzRelease` worker
|
||||
- [ ] #12 All new user-facing strings are wrapped in gettext and `.pot`/`.po` files are regenerated
|
||||
- [ ] #13 LiveView tests cover: add-to-cart, remove, change format, dedup, 1-item sync path (assert navigation + toast, `refute_enqueued`), N-item async path (`assert_enqueued` per item)
|
||||
- [ ] #14 New worker test covers happy path and error path
|
||||
- [ ] #15 Obsolete `IndexActions.handle_import/3` and `handle_event("import", ...)` clauses in Collection/Wishlist index LiveViews are removed
|
||||
- [ ] #16 `StatsLive.Index` import event handler is unchanged
|
||||
<!-- AC:END -->
|
||||
Reference in New Issue
Block a user