diff --git a/web/css/13-scan.css b/web/css/13-scan.css index 8afdcbd..a11cddf 100644 --- a/web/css/13-scan.css +++ b/web/css/13-scan.css @@ -40,10 +40,6 @@ gap: 10px; padding: 10px 0; } -.scan-row__body--added { - color: var(--owned); - font: 700 13px/1 var(--mono); -} .scan-row__main { flex: 1; min-width: 0; @@ -57,14 +53,6 @@ .scan-row__dismiss { padding: 6px 10px; } -.scan-suggestions { - margin-top: 6px; - padding-left: 12px; - border-left: 2px solid var(--line); -} -.scan-suggestions .section-label { - margin-top: 4px; -} .results-scroll { margin-top: 12px; max-height: 320px; diff --git a/web/css/18-scan-batch.css b/web/css/18-scan-batch.css new file mode 100644 index 0000000..de82fdb --- /dev/null +++ b/web/css/18-scan-batch.css @@ -0,0 +1,154 @@ +/* --- scan capture tray -------------------------------------------------- */ +.scan-tray { + margin: 16px; + background: var(--surface); + border: 2px solid var(--ink); + box-shadow: var(--shadow-sm); +} +.scan-tray__header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); +} +.scan-tray__count { + font: 700 13px/1 var(--mono); + letter-spacing: 0.4px; + /* Grows as barcodes land; fixed-width digits stop the label re-flowing. */ + font-variant-numeric: tabular-nums; +} +.scan-tray__hint { + font-size: 12px; + color: var(--ink-muted); +} +.scan-tray__rows { + margin: 0; + padding: 0; + list-style: none; + max-height: 260px; + overflow-y: auto; +} +.scan-tray__row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; +} +.scan-tray__row + .scan-tray__row { + border-top: 1px solid var(--line); +} +.scan-tray__item { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} +.scan-tray__item .cover { + width: 36px; + height: 36px; +} +.scan-tray__info { + flex: 1; + min-width: 0; +} +.scan-tray__info strong { + display: block; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.scan-tray__state { + flex: 1; + font-size: 12px; + color: var(--ink-muted); +} +.scan-tray__cta { + padding: 12px 14px; +} +.scan-tray__cta .btn { + width: 100%; +} + +/* --- review page (buckets) ---------------------------------------------- */ +.scan-review .scan-rows { + margin: 0; + padding: 0; + list-style: none; +} +.scan-review .scan-row + .scan-row { + border-top: 1px solid var(--line); +} +.item-meta--alert { + color: var(--danger); +} +/* Fixed at the bottom of the page-scroll body, above the safe area, so the + CTA is always reachable regardless of how many rows are in each bucket. */ +.scan-review__cta { + position: sticky; + bottom: 0; + padding: 12px 0 calc(4px + env(safe-area-inset-bottom)); + background: var(--bg); +} + +/* --- done page (import ceremony) ---------------------------------------- */ +.done-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 10px; + background: var(--surface); + border: 2px solid var(--ink); + box-shadow: var(--shadow-md); + padding: 32px 20px 22px; + margin-top: 24px; +} +.done-state__sticker { + background: var(--owned); + color: var(--cream-fixed); + border: 2px solid var(--ink); + box-shadow: var(--shadow-xs); + padding: 5px 10px; + font: 700 12px/1 var(--mono); + letter-spacing: 0.5px; + text-transform: uppercase; +} +.done-state__title { + font: 400 30px/1.05 var(--display); + letter-spacing: 0.5px; + margin: 6px 0 0; +} +.done-state__sub { + font: italic 400 14px/1.4 var(--serif); + color: var(--ink-muted); + margin: 0 0 4px; +} +.done-state__count { + display: flex; + align-items: baseline; + gap: 8px; + font: 700 22px/1 var(--mono); + margin: 6px 0 14px; + /* The old -> new counts animate (see effects.count_up); fixed-width + digits keep the layout still while the numbers tick up. */ + font-variant-numeric: tabular-nums; +} +.done-state__count-old { + color: var(--ink-muted); +} +.done-state__count-new { + color: var(--owned); +} +.done-state .btn { + width: 100%; +} + +@media (prefers-reduced-motion: reduce) { + .done-state__count-new { + transition: none; + } +} diff --git a/web/src/at_record_web/browser.gleam b/web/src/at_record_web/browser.gleam index c064bd5..4c80c69 100644 --- a/web/src/at_record_web/browser.gleam +++ b/web/src/at_record_web/browser.gleam @@ -29,3 +29,6 @@ pub fn start_scanner( @external(javascript, "./ffi.mjs", "stopScanner") pub fn stop_scanner() -> Nil + +@external(javascript, "./ffi.mjs", "countUp") +pub fn count_up(element_id: String, from: Int, to: Int, ms: Int) -> Nil diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index da60a2b..a982618 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -23,8 +23,8 @@ import at_record_web/msg.{ GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, GotHandleSuggestions, - GotLogout, GotScanResult, GotScanSeen, GotShelf, GotTimeline, ScanItemAdded, - ScanLookup, ShelfData, TimelineData, + GotLogout, GotScanResult, GotScanSeen, GotShelf, GotTimeline, ScanLookup, + ShelfData, TimelineData, } import gleam/dict import gleam/dynamic/decode @@ -501,21 +501,30 @@ pub fn add_item(form: Form) -> Effect(Msg) { ) } -/// Same write as `add_item`, tagged with the barcode so the scan row that -/// triggered it can be found again when the response comes back. -pub fn add_scanned(barcode: String, form: Form) -> Effect(Msg) { +/// Same write as `add_item`, tagged with the barcode so the batch importer's +/// row can be found again when the response comes back; `on_done` is the msg +/// constructor to dispatch (the sequential batch loop). +pub fn add_scanned( + barcode: String, + form: Form, + on_done: fn(String, Result(Nil, rsvp.Error(String))) -> Msg, +) -> Effect(Msg) { rsvp.post( xrpc("shelf.addEntry"), add_body(form), - rsvp.expect_json(nil_decoder(), ScanItemAdded(barcode, _)), + rsvp.expect_json(nil_decoder(), on_done(barcode, _)), ) } -/// A network-matched scan row's ADD: the release is already known, so this -/// adopts it exactly like the browse grid's "I HAVE THIS" (owned status), -/// tagged with the barcode like `add_scanned` so the triggering row can be -/// found again when the response comes back. -pub fn adopt_scanned(barcode: String, uri: String, cid: String) -> Effect(Msg) { +/// A network-matched scan row's import: the release is already known, so +/// this adopts it exactly like the browse grid's "I HAVE THIS" (owned +/// status), tagged with the barcode like `add_scanned`. +pub fn adopt_scanned( + barcode: String, + uri: String, + cid: String, + on_done: fn(String, Result(Nil, rsvp.Error(String))) -> Msg, +) -> Effect(Msg) { let body = json.object([ #("uri", json.string(uri)), @@ -525,10 +534,17 @@ pub fn adopt_scanned(barcode: String, uri: String, cid: String) -> Effect(Msg) { rsvp.post( xrpc("catalog.adoptRelease"), body, - rsvp.expect_json(nil_decoder(), ScanItemAdded(barcode, _)), + rsvp.expect_json(nil_decoder(), on_done(barcode, _)), ) } +/// Drive the done page's count-up from `from` to `to` over `ms`; a direct DOM +/// write (like `start_scanner`), not model-driven, and a no-op under +/// prefers-reduced-motion (handled in the FFI). +pub fn count_up(element_id: String, from: Int, to: Int) -> Effect(Msg) { + effect.from(fn(_dispatch) { browser.count_up(element_id, from, to, 300) }) +} + /// The shared catalog grid: releases published by known at-record users, /// folded against the viewer's own crate. pub fn load_browse() -> Effect(Msg) { diff --git a/web/src/at_record_web/ffi.mjs b/web/src/at_record_web/ffi.mjs index b29ceb2..b13da07 100644 --- a/web/src/at_record_web/ffi.mjs +++ b/web/src/at_record_web/ffi.mjs @@ -134,3 +134,27 @@ export function stopScanner() { scanStream = null; } } + +const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3); + +// Ticks an element's text content from `from` to `to`; a no-op jump under +// prefers-reduced-motion rather than a shorter animation, per the app's +// existing reduced-motion convention (see 90-interaction.css). +export function countUp(elementId, from, to, ms) { + const el = document.getElementById(elementId); + if (!el) return; + const reduceMotion = + globalThis.matchMedia && + globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduceMotion || from === to) { + el.textContent = String(to); + return; + } + const start = performance.now(); + function tick(now) { + const t = Math.min(1, (now - start) / ms); + el.textContent = String(Math.round(from + (to - from) * easeOutCubic(t))); + if (t < 1) requestAnimationFrame(tick); + } + requestAnimationFrame(tick); +} diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 01fa8cb..ce6a6a4 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -16,6 +16,8 @@ pub type Route { Crate Add Scan + ScanReview + ScanDone Record(entry_id: String) Browse EditInbox @@ -223,13 +225,16 @@ pub type ScanItem { } /// One scanned barcode's outcome, from lookup through commit. `NoMatch` carries -/// any "did you mean" suggestions (empty when there are none). +/// any "did you mean" suggestions (empty when there are none). `Failed` is a +/// batch-import attempt that errored, keeping its item so a RETRY can re-fire +/// the same write without re-running the barcode lookup. pub type ScanOutcome { Pending Matched(result: DiscogsResult, owned: Bool) NetworkMatched(release: NetworkMatch) NoMatch(suggestions: List(Suggestion)) Adding(item: ScanItem) + Failed(item: ScanItem) Added } @@ -238,18 +243,45 @@ pub type ScanRow { } /// Barcode-scan session state: the camera's readiness, the discogs ids -/// already in the crate (fetched once per session, not once per scan), and -/// the batch of rows scanned so far, newest first. +/// already in the crate (fetched once per session, not once per scan), the +/// batch of rows scanned so far (newest first), and the review page's batch +/// import run: `import_old_count` is the crate size snapshotted when the run +/// started (for the done page's old -> new), `import_added` is a running +/// tally of successes in the current (or most recently finished) run. pub type ScanState { ScanState( camera_error: Option(String), seen_ids: set.Set(String), rows: List(ScanRow), + importing: Bool, + import_old_count: Int, + import_added: Int, ) } pub fn blank_scan() -> ScanState { - ScanState(camera_error: None, seen_ids: set.new(), rows: []) + ScanState( + camera_error: None, + seen_ids: set.new(), + rows: [], + importing: False, + import_old_count: 0, + import_added: 0, + ) +} + +/// Rows the bulk batch loop still cares about: matched candidates not yet +/// committed, or in flight. Drives the review page's "IMPORT N -> CRATE" +/// count and which rows a batch run picks up; a `Failed` row is deliberately +/// excluded from both - it's only ever retried via its own RETRY tap, never +/// automatically re-swept by the bulk button, so it can't loop on itself. +pub fn scan_importable(rows: List(ScanRow)) -> List(ScanRow) { + list.filter(rows, fn(row) { + case row.outcome { + Matched(owned: False, ..) | NetworkMatched(_) | Adding(_) -> True + _ -> False + } + }) } /// Draft of a manual catalog amendment (all free text; lists comma-split). diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 6e23764..f6800a3 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -108,10 +108,10 @@ pub type Msg { GotScanSeen(Result(Set(String), rsvp.Error(String))) BarcodeDetected(String) GotScanResult(barcode: String, result: Result(ScanLookup, rsvp.Error(String))) - AddScannedItem(barcode: String) - AddSuggestion(barcode: String, release: DiscogsResult) - ScanItemAdded(barcode: String, result: Result(Nil, rsvp.Error(String))) RemoveScanRow(barcode: String) + StartBatchImport + BatchItemDone(barcode: String, result: Result(Nil, rsvp.Error(String))) + RetryBatchItem(barcode: String) GotBrowse(Result(List(BrowseRelease), rsvp.Error(String))) BrowseQuery(String) BrowseGenre(String) diff --git a/web/src/at_record_web/pages/scan.gleam b/web/src/at_record_web/pages/scan.gleam index 915c171..6f983e7 100644 --- a/web/src/at_record_web/pages/scan.gleam +++ b/web/src/at_record_web/pages/scan.gleam @@ -1,30 +1,29 @@ -//// The Scan page: a live camera view detects barcodes; each scan becomes a -//// row (matched / owned / no match) the user reviews and adds one at a time. -//// Barcode-only for now (BarcodeDetector where the browser has it); photo -//// matching has no backend yet, per the roadmap. +//// The Scan page: a live camera view detects barcodes; each scan lands in a +//// compact tray below it. No inline add here (that moved to the review +//// page) - this page is capture-only. Barcode-only for now (BarcodeDetector +//// where the browser has it); photo matching has no backend yet. import at_record_web/model.{ - type Model, type NetworkMatch, type ScanItem, type ScanOutcome, type ScanRow, - type Suggestion, Added, Adding, FromDiscogs, FromNetwork, Matched, - NetworkMatched, NoMatch, Pending, + type Model, type ScanItem, type ScanOutcome, type ScanRow, Added, Adding, + Failed, Matched, NetworkMatched, NoMatch, Pending, ScanReview, } -import at_record_web/msg.{type Msg, AddScannedItem, AddSuggestion, RemoveScanRow} +import at_record_web/msg.{type Msg} +import at_record_web/pages/scan_common.{dismiss_button, item_thumb, item_title} +import at_record_web/route import at_record_web/ui/controls as ctl import at_record_web/ui/covers as cov import at_record_web/ui/forms as frm import gleam/int import gleam/list -import gleam/option.{None, Some} -import gleam/string +import gleam/option.{type Option, None, Some} import lustre/attribute as attr import lustre/element.{type Element, text} import lustre/element/html -import lustre/event pub fn view(model: Model) -> Element(Msg) { html.div([attr.class("body page-scroll")], [ camera_view(model), - rows_view(model.scan.rows, model.busy), + tray_view(model.scan.rows), ]) } @@ -55,181 +54,70 @@ fn camera_view(model: Model) -> Element(Msg) { } } -fn rows_view(rows: List(ScanRow), busy: Bool) -> Element(Msg) { +fn tray_view(rows: List(ScanRow)) -> Element(Msg) { case rows { [] -> element.none() _ -> - cov.card([ - frm.section_label("SCANNED"), - html.ul([attr.class("scan-rows")], list.map(rows, row_view(_, busy))), - ]) - } -} - -fn row_view(row: ScanRow, busy: Bool) -> Element(Msg) { - html.li([attr.class("scan-row")], [ - outcome_view(row.barcode, row.outcome, busy), - ]) -} - -fn outcome_view( - barcode: String, - outcome: ScanOutcome, - busy: Bool, -) -> Element(Msg) { - case outcome { - Pending -> - html.div([attr.class("scan-row__body")], [ - html.span([attr.class("item-meta")], [text(barcode <> "…")]), - html.span([attr.class("item-meta")], [text("looking up…")]), - ]) - NoMatch(suggestions:) -> - html.div([], [ - html.div([attr.class("scan-row__body")], [ - html.div([], [ - html.strong([], [text(barcode)]), - html.div([attr.class("item-meta")], [text("no Discogs match")]), - ]), - ctl.link_button("SEARCH", ctl.Ghost, "/add"), - ctl.button("DISMISS", ctl.Ghost, [ - event.on_click(RemoveScanRow(barcode)), + html.div([attr.class("scan-tray")], [ + html.div([attr.class("scan-tray__header")], [ + html.span([attr.class("scan-tray__count")], [ + text(int.to_string(list.length(rows)) <> " SCANNED"), ]), + html.span([attr.class("scan-tray__hint")], [text("ready to review")]), ]), - suggestions_view(barcode, suggestions, busy), - ]) - Added -> - html.div([attr.class("scan-row__body scan-row__body--added")], [ - html.span([], [text("✓ added")]), - ]) - Adding(item:) -> adding_view(item) - NetworkMatched(release:) -> network_view(barcode, release, busy) - Matched(result:, owned:) -> { - let meta = - [result.year |> option.map(int.to_string), result.format] - |> option.values - |> string.join(" · ") - html.div([attr.class("scan-row__body")], [ - cov.thumb(result.thumb_url), - html.div([attr.class("scan-row__main")], [ - html.strong([], [text(result.title)]), - html.div([attr.class("item-meta")], [ - text(result.artist <> " · " <> meta), - ]), - html.div([attr.class("item-meta")], [ - text( - "◆ matched via Discogs · release " - <> int.to_string(result.discogs_id), - ), - ]), + html.ul([attr.class("scan-tray__rows")], list.map(rows, tray_row)), + html.div([attr.class("scan-tray__cta")], [ + ctl.link_button("REVIEW →", ctl.Primary, route.to_path(ScanReview)), ]), - case owned { - True -> ctl.badge("ALREADY OWNED", ctl.Owned) - False -> - ctl.button("ADD", ctl.Primary, [ - event.on_click(AddScannedItem(barcode)), - attr.disabled(busy), - ]) - }, - dismiss_button(barcode), ]) - } - } -} - -/// The in-flight add row, shared by both a Discogs add and a network adopt. -fn adding_view(item: ScanItem) -> Element(Msg) { - let #(thumb_url, title) = case item { - FromDiscogs(result) -> #(result.thumb_url, result.title) - FromNetwork(release) -> #(release.thumb_url, release.title) } - html.div([attr.class("scan-row__body")], [ - cov.thumb(thumb_url), - html.div([attr.class("scan-row__main")], [ - html.strong([], [text(title)]), - html.div([attr.class("item-meta")], [text("adding…")]), - ]), - ]) } -/// A release already found in the shared at-record catalog: same card shape -/// as a Discogs match, with a browse-style "via @handle" attribution line; -/// ADD adopts it instead of running the manual add form. -fn network_view( - barcode: String, - release: NetworkMatch, - busy: Bool, -) -> Element(Msg) { - let artist = option.unwrap(release.artist_display, "") - html.div([attr.class("scan-row__body")], [ - cov.thumb(release.thumb_url), - html.div([attr.class("scan-row__main")], [ - html.strong([], [text(release.title)]), - html.div([attr.class("item-meta")], [text(artist)]), - via_handle(release.publisher_handle), - ]), - ctl.button("ADD", ctl.Primary, [ - event.on_click(AddScannedItem(barcode)), - attr.disabled(busy), - ]), - dismiss_button(barcode), +fn tray_row(row: ScanRow) -> Element(Msg) { + html.li([attr.class("scan-tray__row")], [ + tray_body(row.outcome), + dismiss_button(row.barcode), ]) } -fn dismiss_button(barcode: String) -> Element(Msg) { - ctl.button("×", ctl.Ghost, [ - attr.class("scan-row__dismiss"), - attr.type_("button"), - attr.aria_label("Dismiss " <> barcode), - event.on_click(RemoveScanRow(barcode)), - ]) +fn tray_body(outcome: ScanOutcome) -> Element(Msg) { + case outcome { + Pending -> tray_label("looking up…") + NoMatch(_) -> tray_label("no match") + Added -> tray_label("✓ added") + Adding(item:) -> tray_item(item, "adding…") + Failed(item:) -> tray_item(item, "failed") + NetworkMatched(release:) -> + tray_thumb( + release.thumb_url, + release.title, + option.unwrap(release.artist_display, ""), + ) + Matched(result:, owned: True) -> + tray_thumb(result.thumb_url, result.title, "already owned") + Matched(result:, owned: False) -> + tray_thumb(result.thumb_url, result.title, result.artist) + } } -fn via_handle(handle: option.Option(String)) -> Element(Msg) { - case handle { - Some(h) -> html.span([attr.class("item-meta")], [text("via @" <> h)]) - None -> element.none() - } +fn tray_label(label: String) -> Element(Msg) { + html.span([attr.class("scan-tray__state")], [text(label)]) } -/// "Did you mean?" rows under a no-match barcode: at most two MusicBrainz-seeded -/// Discogs candidates, each addable exactly like a matched row. -fn suggestions_view( - barcode: String, - suggestions: List(Suggestion), - busy: Bool, -) -> Element(Msg) { - case suggestions { - [] -> element.none() - _ -> - html.div([attr.class("scan-suggestions")], [ - frm.section_label("DID YOU MEAN?"), - ..list.map(list.take(suggestions, 2), suggestion_view(barcode, _, busy)) - ]) - } +fn tray_item(item: ScanItem, meta: String) -> Element(Msg) { + tray_thumb(item_thumb(item), item_title(item), meta) } -fn suggestion_view( - barcode: String, - suggestion: Suggestion, - busy: Bool, +fn tray_thumb( + thumb: Option(String), + title: String, + meta: String, ) -> Element(Msg) { - let result = suggestion.release - let meta = - [result.year |> option.map(int.to_string), result.format] - |> option.values - |> string.join(" · ") - html.div([attr.class("scan-row__body")], [ - cov.thumb(result.thumb_url), - html.div([attr.class("scan-row__main")], [ - html.strong([], [text(result.title)]), - html.div([attr.class("item-meta")], [ - text(result.artist <> " · " <> meta), - ]), - ]), - ctl.chip(int.to_string(suggestion.confidence) <> "%", ctl.Surface), - ctl.button("ADD", ctl.Primary, [ - event.on_click(AddSuggestion(barcode, result)), - attr.disabled(busy), + html.div([attr.class("scan-tray__item")], [ + cov.thumb(thumb), + html.div([attr.class("scan-tray__info")], [ + html.strong([], [text(title)]), + html.span([attr.class("item-meta")], [text(meta)]), ]), ]) } diff --git a/web/src/at_record_web/pages/scan_common.gleam b/web/src/at_record_web/pages/scan_common.gleam new file mode 100644 index 0000000..9b8d9c9 --- /dev/null +++ b/web/src/at_record_web/pages/scan_common.gleam @@ -0,0 +1,42 @@ +//// Shared view helpers for the scan capture/review/done trio: pulling a +//// thumbnail/title out of either kind of `ScanItem`, and the barcode dismiss +//// control every row (whatever its outcome) offers. + +import at_record_web/model.{type ScanItem, FromDiscogs, FromNetwork} +import at_record_web/msg.{type Msg, RemoveScanRow} +import at_record_web/ui/controls as ctl +import gleam/option.{type Option, None, Some} +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +pub fn item_thumb(item: ScanItem) -> Option(String) { + case item { + FromDiscogs(result) -> result.thumb_url + FromNetwork(release) -> release.thumb_url + } +} + +pub fn item_title(item: ScanItem) -> String { + case item { + FromDiscogs(result) -> result.title + FromNetwork(release) -> release.title + } +} + +pub fn dismiss_button(barcode: String) -> Element(Msg) { + ctl.button("×", ctl.Ghost, [ + attr.class("scan-row__dismiss"), + attr.type_("button"), + attr.aria_label("Dismiss " <> barcode), + event.on_click(RemoveScanRow(barcode)), + ]) +} + +pub fn via_handle(handle: Option(String)) -> Element(Msg) { + case handle { + Some(h) -> html.span([attr.class("item-meta")], [text("via @" <> h)]) + None -> element.none() + } +} diff --git a/web/src/at_record_web/pages/scan_done.gleam b/web/src/at_record_web/pages/scan_done.gleam new file mode 100644 index 0000000..39ca41f --- /dev/null +++ b/web/src/at_record_web/pages/scan_done.gleam @@ -0,0 +1,51 @@ +//// The post-import ceremony: how many records just landed, and the crate's +//// old -> new count. The new count renders as its final value up front (a +//// no-JS/reduced-motion-safe fallback); `effects.count_up` then ticks the +//// `#done-count` span from old to new on entry (see update.gleam's +//// OnRouteChange(ScanDone)). + +import at_record_web/model.{type Model, Crate, Scan} +import at_record_web/msg.{type Msg} +import at_record_web/route +import gleam/int +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html + +pub fn view(model: Model) -> Element(Msg) { + let old = model.scan.import_old_count + let added = model.scan.import_added + let new_count = old + added + html.div([attr.class("body page-scroll")], [ + html.div([attr.class("done-state")], [ + html.span([attr.class("done-state__sticker")], [text("✓ IMPORTED")]), + html.h2([attr.class("done-state__title")], [ + text(int.to_string(added) <> " RECORDS ADDED"), + ]), + html.p([attr.class("done-state__sub")], [text("your crate grew")]), + html.p([attr.class("done-state__count")], [ + html.span([attr.class("done-state__count-old")], [ + text(int.to_string(old)), + ]), + text(" → "), + html.span([attr.id("done-count"), attr.class("done-state__count-new")], [ + text(int.to_string(new_count)), + ]), + ]), + html.a( + [ + attr.class("btn btn--primary btn--block"), + attr.href(route.to_path(Crate)), + ], + [text("VIEW CRATE")], + ), + html.a( + [ + attr.class("btn btn--ghost btn--block"), + attr.href(route.to_path(Scan)), + ], + [text("SCAN MORE")], + ), + ]), + ]) +} diff --git a/web/src/at_record_web/pages/scan_review.gleam b/web/src/at_record_web/pages/scan_review.gleam new file mode 100644 index 0000000..8762c60 --- /dev/null +++ b/web/src/at_record_web/pages/scan_review.gleam @@ -0,0 +1,190 @@ +//// The batch review page ("review your haul"): scanned rows bucketed into +//// MATCHED (importable), already-in-your-crate duplicates (excluded), and +//// NEEDS REVIEW (no Discogs match), with a single CTA that runs the import +//// sequentially over the matched bucket. + +import at_record_web/model.{ + type Model, type ScanRow, Adding, Failed, Matched, NetworkMatched, NoMatch, + Scan, +} +import at_record_web/msg.{type Msg, RetryBatchItem, StartBatchImport} +import at_record_web/pages/scan_common.{ + dismiss_button, item_thumb, item_title, via_handle, +} +import at_record_web/route +import at_record_web/ui/controls as ctl +import at_record_web/ui/covers as cov +import gleam/int +import gleam/list +import gleam/option +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +pub fn view(model: Model) -> Element(Msg) { + case model.scan.rows { + [] -> empty_review() + rows -> loaded_review(model, rows) + } +} + +fn empty_review() -> Element(Msg) { + html.div([attr.class("body page-scroll")], [ + html.p([attr.class("empty")], [text("Nothing scanned yet.")]), + ctl.link_button("← BACK TO SCAN", ctl.Ghost, route.to_path(Scan)), + ]) +} + +fn loaded_review(model: Model, rows: List(ScanRow)) -> Element(Msg) { + let importing = model.scan.importing + let matched = list.filter(rows, is_matched) + let duplicates = list.filter(rows, is_duplicate) + let needs_review = list.filter(rows, is_needs_review) + let importable_count = list.length(model.scan_importable(rows)) + html.div([attr.class("body page-scroll scan-review")], [ + bucket_section("MATCHED", matched, fn(row) { matched_row(row, importing) }), + bucket_section("ALREADY IN YOUR CRATE", duplicates, duplicate_row), + bucket_section("NEEDS REVIEW", needs_review, needs_review_row), + import_cta(importable_count, importing), + ]) +} + +fn is_matched(row: ScanRow) -> Bool { + case row.outcome { + Matched(owned: False, ..) | NetworkMatched(_) | Adding(_) | Failed(_) -> + True + _ -> False + } +} + +fn is_duplicate(row: ScanRow) -> Bool { + case row.outcome { + Matched(owned: True, ..) -> True + _ -> False + } +} + +fn is_needs_review(row: ScanRow) -> Bool { + case row.outcome { + NoMatch(_) -> True + _ -> False + } +} + +fn bucket_section( + label: String, + rows: List(ScanRow), + row_view: fn(ScanRow) -> Element(Msg), +) -> Element(Msg) { + case rows { + [] -> element.none() + _ -> + cov.card([ + section_label(label), + html.ul([attr.class("scan-rows")], list.map(rows, row_view)), + ]) + } +} + +fn section_label(label: String) -> Element(Msg) { + html.p([attr.class("section-label")], [text(label)]) +} + +fn matched_row(row: ScanRow, importing: Bool) -> Element(Msg) { + html.li([attr.class("scan-row")], [matched_body(row, importing)]) +} + +fn matched_body(row: ScanRow, importing: Bool) -> Element(Msg) { + case row.outcome { + Matched(result:, owned: False) -> + html.div([attr.class("scan-row__body")], [ + cov.thumb(result.thumb_url), + html.div([attr.class("scan-row__main")], [ + ctl.chip("MATCHED", ctl.Accent), + html.strong([], [text(result.title)]), + html.div([attr.class("item-meta")], [text(result.artist)]), + ]), + dismiss_button(row.barcode), + ]) + NetworkMatched(release:) -> + html.div([attr.class("scan-row__body")], [ + cov.thumb(release.thumb_url), + html.div([attr.class("scan-row__main")], [ + ctl.chip("MATCHED", ctl.Accent), + html.strong([], [text(release.title)]), + html.div([attr.class("item-meta")], [ + text(option.unwrap(release.artist_display, "")), + ]), + via_handle(release.publisher_handle), + ]), + dismiss_button(row.barcode), + ]) + Adding(item:) -> + html.div([attr.class("scan-row__body")], [ + cov.thumb(item_thumb(item)), + html.div([attr.class("scan-row__main")], [ + html.strong([], [text(item_title(item))]), + html.div([attr.class("item-meta")], [text("adding…")]), + ]), + ]) + Failed(item:) -> + html.div([attr.class("scan-row__body")], [ + cov.thumb(item_thumb(item)), + html.div([attr.class("scan-row__main")], [ + html.strong([], [text(item_title(item))]), + html.div([attr.class("item-meta item-meta--alert")], [ + text("failed to add"), + ]), + ]), + ctl.button("RETRY", ctl.Ghost, [ + event.on_click(RetryBatchItem(row.barcode)), + attr.disabled(importing), + ]), + dismiss_button(row.barcode), + ]) + _ -> element.none() + } +} + +fn duplicate_row(row: ScanRow) -> Element(Msg) { + case row.outcome { + Matched(result:, owned: True) -> + html.li([attr.class("scan-row")], [ + html.div([attr.class("scan-row__body")], [ + cov.thumb(result.thumb_url), + html.div([attr.class("scan-row__main")], [ + ctl.chip("DUPLICATE", ctl.Owned), + html.strong([], [text(result.title)]), + html.div([attr.class("item-meta")], [text(result.artist)]), + ]), + dismiss_button(row.barcode), + ]), + ]) + _ -> element.none() + } +} + +fn needs_review_row(row: ScanRow) -> Element(Msg) { + html.li([attr.class("scan-row")], [ + html.div([attr.class("scan-row__body")], [ + html.div([attr.class("scan-row__main")], [ + ctl.chip("REVIEW", ctl.Wanted), + html.strong([], [text(row.barcode)]), + html.div([attr.class("item-meta")], [text("no Discogs match")]), + ]), + ctl.link_button("SEARCH", ctl.Ghost, "/add"), + dismiss_button(row.barcode), + ]), + ]) +} + +fn import_cta(count: Int, importing: Bool) -> Element(Msg) { + html.div([attr.class("scan-review__cta")], [ + ctl.button("IMPORT " <> int.to_string(count) <> " → CRATE", ctl.Primary, [ + attr.class("btn--block"), + event.on_click(StartBatchImport), + attr.disabled(count == 0 || importing), + ]), + ]) +} diff --git a/web/src/at_record_web/route.gleam b/web/src/at_record_web/route.gleam index 98434dc..9022823 100644 --- a/web/src/at_record_web/route.gleam +++ b/web/src/at_record_web/route.gleam @@ -1,7 +1,8 @@ //// URL <-> Route mapping for modem. import at_record_web/model.{ - type Route, Add, Browse, Crate, EditInbox, Record, Scan, Settings, + type Route, Add, Browse, Crate, EditInbox, Record, Scan, ScanDone, ScanReview, + Settings, } import gleam/uri.{type Uri} @@ -9,6 +10,8 @@ pub fn parse(target: Uri) -> Route { case uri.path_segments(target.path) { ["add"] -> Add ["scan"] -> Scan + ["scan", "review"] -> ScanReview + ["scan", "done"] -> ScanDone ["browse"] -> Browse ["inbox"] -> EditInbox ["settings"] -> Settings @@ -22,6 +25,8 @@ pub fn to_path(route: Route) -> String { Crate -> "/" Add -> "/add" Scan -> "/scan" + ScanReview -> "/scan/review" + ScanDone -> "/scan/done" Browse -> "/browse" EditInbox -> "/inbox" Settings -> "/settings" diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 924eac7..3f51e6e 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -8,28 +8,29 @@ import at_record_web/model.{ type BrowseRelease, type Entry, type Model, type Notice, Add, Browse, Discogs, EditInbox, Entry, Failure, Form, InboxLoaded, InboxLoading, LoggedIn, LoggedOut, Model, Notice, ProposalApplied, ProposalApplying, ProposalReviewing, - Record, Scan, ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, - blank_form, crate_window_size, + Record, Scan, ScanDone, ScanReview, ShelfFailed, ShelfLoaded, ShelfLoading, + Success, Warning, blank_form, crate_window_size, } import at_record_web/money import at_record_web/msg.{ - type Msg, type ScanLookup, AddScannedItem, AddSuggestion, AmendField, - AppliedProposal, ApplyProposal, ArmLogout, ArmRemove, ArtistSearch, - BarcodeDetected, BrowseAdd, BrowseGenre, BrowseQuery, CameraUnsupported, - ClearNotice, CoverFileChosen, CoverUploaded, DisarmLogout, DisarmRemove, - DiscogsConnect, DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, - DiscogsLoadMore, DiscogsSearch, DiscogsVinylOnly, EntryAction, FormArtist, - FormCounterparty, FormFolder, FormFormat, FormPriceAmount, FormPriceCurrency, - FormRating, FormSleeveGrade, FormStatus, FormTitle, FormYear, GotAction, - GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, - GotBrowseAdd, GotBrowseSearch, GotDiscogs, GotDiscogsDisconnect, - GotDiscogsImport, GotDiscogsStatus, GotEditInbox, GotHandleSuggestions, - GotLogout, GotScanResult, GotScanSeen, GotShelf, GotTimeline, HandleChanged, - HandleSearch, IgnoreProposal, Logout, OnRouteChange, Rate, Regrade, - RemoveScanRow, RetryShelf, ScanItemAdded, SetDisplay, SetView, ShowMoreCrate, - StartLogin, SubmitAdd, SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, - ToggleEntry, TriggerBrowseSearch, UseArtist, UseDiscogs, UseHandleSuggestion, + type Msg, type ScanLookup, AmendField, AppliedProposal, ApplyProposal, + ArmLogout, ArmRemove, ArtistSearch, BarcodeDetected, BatchItemDone, BrowseAdd, + BrowseGenre, BrowseQuery, CameraUnsupported, ClearNotice, CoverFileChosen, + CoverUploaded, DisarmLogout, DisarmRemove, DiscogsConnect, DiscogsDisconnect, + DiscogsImport, DiscogsImportWantlist, DiscogsLoadMore, DiscogsSearch, + DiscogsVinylOnly, EntryAction, FormArtist, FormCounterparty, FormFolder, + FormFormat, FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, + FormStatus, FormTitle, FormYear, GotAction, GotAdd, GotAmend, GotApplyProposal, + GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotDiscogs, + GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, + GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, GotShelf, + GotTimeline, HandleChanged, HandleSearch, IgnoreProposal, Logout, + OnRouteChange, Rate, Regrade, RemoveScanRow, RetryBatchItem, RetryShelf, + SetDisplay, SetView, ShowMoreCrate, StartBatchImport, StartLogin, SubmitAdd, + SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, ToggleEntry, + TriggerBrowseSearch, UseArtist, UseDiscogs, UseHandleSuggestion, } +import at_record_web/route import gleam/bool import gleam/int import gleam/list @@ -38,6 +39,7 @@ import gleam/result import gleam/set import gleam/string import lustre/effect.{type Effect} +import modem import rsvp pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { @@ -61,9 +63,43 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { disarm_confirms(), ]), ) + // Entering the done page needs the pre-navigation model (the run's + // old/added counts) to size the count-up, so it gets its own arm rather + // than folding into the generic case below. + OnRouteChange(ScanDone) -> { + let updated = + Model( + ..model, + route: ScanDone, + selected: None, + editing: False, + confirm_remove: False, + confirm_logout: False, + timeline: [], + release_info: None, + browse_adding: None, + ) + let new_count = updated.scan.import_old_count + updated.scan.import_added + #( + updated, + effect.batch([ + leaving_scan(model), + effects.count_up( + "done-count", + updated.scan.import_old_count, + new_count, + ), + ]), + ) + } + // Add refreshes Discogs connection status; scan starts/stops the camera; // browse reloads the shared catalog; the edit inbox loads its proposals // only on entry (never at login) and clears any stale in-flight add. + // Scan's own row state is handled by `scan_for_route`: a fresh entry + // (from outside the scan/review/done trio) starts a blank session, while + // moving within the trio preserves it so the review page survives + // navigation. OnRouteChange(route) -> #( Model( ..model, @@ -79,10 +115,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Browse | EditInbox -> True _ -> model.busy }, - scan: case route { - Scan -> model.blank_scan() - _ -> model.scan - }, + scan: scan_for_route(model.scan, model.route, route), inbox: case route { EditInbox -> InboxLoading _ -> model.inbox @@ -735,79 +768,51 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Model(..model, scan: update_row(model.scan, barcode, model.NoMatch([]))), effect.none(), ) - // Busy-guarded against a double-tap firing a second write for the same - // barcode. The row switches to Adding right away so a batch of scans - // shows per-row progress instead of the whole list going inert. A - // network match adopts via `adoptRelease` (owned status), same write the - // browse grid's quick actions use; a Discogs match keeps today's path. - AddScannedItem(barcode) -> - case model.busy, find_row(model.scan, barcode) { - False, Some(model.ScanRow(outcome: model.Matched(result:, ..), ..)) -> #( - Model( - ..model, - busy: True, - scan: update_row( - model.scan, - barcode, - model.Adding(model.FromDiscogs(result)), - ), - ), - effects.add_scanned(barcode, model.form_from_discogs(result)), - ) - False, Some(model.ScanRow(outcome: model.NetworkMatched(release:), ..)) - -> #( - Model( - ..model, - busy: True, - scan: update_row( - model.scan, - barcode, - model.Adding(model.FromNetwork(release)), - ), - ), - effects.adopt_scanned(barcode, release.uri, release.cid), - ) + // Kicks off the sequential batch import: one write in flight at a time, + // picked up again by BatchItemDone until nothing importable remains. + // Guarded against double-taps while a run is already going. + StartBatchImport -> + case model.scan.importing, next_pending(model.scan.rows) { + False, Some(#(barcode, item)) -> { + let scan = + model.ScanState( + ..model.scan, + importing: True, + import_old_count: list.length(model.entries(model)), + import_added: 0, + ) + start_batch_item(Model(..model, scan:), barcode, item) + } _, _ -> #(model, effect.none()) } - // A "did you mean" suggestion carries its release directly (a NoMatch row - // has no single "the" match to look up), but otherwise commits exactly - // like AddScannedItem: same Adding transition, effect, and completion. - AddSuggestion(barcode, release) -> - case model.busy { - True -> #(model, effect.none()) - False -> #( - Model( - ..model, - busy: True, - scan: update_row( - model.scan, - barcode, - model.Adding(model.FromDiscogs(release)), - ), - ), - effects.add_scanned(barcode, model.form_from_discogs(release)), + BatchItemDone(barcode, Ok(Nil)) -> { + let scan = + model.ScanState( + ..update_row(model.scan, barcode, model.Added), + import_added: model.scan.import_added + 1, ) - } - ScanItemAdded(barcode, Ok(Nil)) -> #( - Model( - ..model, - busy: False, - scan: update_row(model.scan, barcode, model.Added), - ), - effect.none(), - ) - ScanItemAdded(barcode, Error(e)) -> { - let reverted = case find_row(model.scan, barcode) { + advance_batch(Model(..model, scan:)) + } + // A failed row stays listed (RETRY) instead of aborting the rest of the run. + BatchItemDone(barcode, Error(_)) -> { + let scan = case find_row(model.scan, barcode) { Some(model.ScanRow(outcome: model.Adding(item:), ..)) -> - update_row(model.scan, barcode, reverted_outcome(item)) + update_row(model.scan, barcode, model.Failed(item)) _ -> model.scan } - write_error( - Model(..model, scan: reverted), - e, - "Could not save that record.", - ) + advance_batch(Model(..model, scan:)) } + // Guarded against a race with the running batch loop touching the same row. + RetryBatchItem(barcode) -> + case model.scan.importing, find_row(model.scan, barcode) { + False, Some(model.ScanRow(outcome: model.Failed(item:), ..)) -> + start_batch_item( + Model(..model, scan: model.ScanState(..model.scan, importing: True)), + barcode, + item, + ) + _, _ -> #(model, effect.none()) + } RemoveScanRow(barcode) -> #( Model( ..model, @@ -1016,11 +1021,100 @@ fn scan_outcome( } } -/// The pre-write outcome a failed add reverts an `Adding` row back to. -fn reverted_outcome(item: model.ScanItem) -> model.ScanOutcome { - case item { - model.FromDiscogs(result) -> model.Matched(result:, owned: False) - model.FromNetwork(release) -> model.NetworkMatched(release) +/// An unstarted row the batch loop will pick up next. `Failed` is +/// deliberately excluded - retrying it is RETRY's job, not the loop's, +/// otherwise a failure would just get re-fired forever. +fn pending_item(outcome: model.ScanOutcome) -> Option(model.ScanItem) { + case outcome { + model.Matched(result:, owned: False) -> Some(model.FromDiscogs(result)) + model.NetworkMatched(release:) -> Some(model.FromNetwork(release)) + _ -> None + } +} + +/// The next row (in scan order) the batch loop should fire, if any. +fn next_pending( + rows: List(model.ScanRow), +) -> Option(#(String, model.ScanItem)) { + rows + |> list.find_map(fn(row) { + pending_item(row.outcome) + |> option.map(fn(item) { #(row.barcode, item) }) + |> option.to_result(Nil) + }) + |> option.from_result +} + +/// Fire one row's write and flip it to `Adding`; shared by the batch loop's +/// first tap and a per-row RETRY. +fn start_batch_item( + model: Model, + barcode: String, + item: model.ScanItem, +) -> #(Model, Effect(Msg)) { + let write_effect = case item { + model.FromDiscogs(result) -> + effects.add_scanned( + barcode, + model.form_from_discogs(result), + BatchItemDone, + ) + model.FromNetwork(release) -> + effects.adopt_scanned(barcode, release.uri, release.cid, BatchItemDone) + } + #( + Model(..model, scan: update_row(model.scan, barcode, model.Adding(item))), + write_effect, + ) +} + +/// After a batch item settles: fire the next pending row, or wrap up the run. +fn advance_batch(model: Model) -> #(Model, Effect(Msg)) { + case next_pending(model.scan.rows) { + Some(#(barcode, item)) -> start_batch_item(model, barcode, item) + None -> finish_batch(model) + } +} + +/// Nothing left to attempt: clear the running flag, and if the run landed at +/// least one success, refresh the shelf and hand off to the done page. +fn finish_batch(model: Model) -> #(Model, Effect(Msg)) { + let scan = model.ScanState(..model.scan, importing: False) + let updated = Model(..model, scan:) + case scan.import_added > 0 { + True -> #( + updated, + effect.batch([ + modem.push(route.to_path(ScanDone), None, None), + load_shelf(model.view), + ]), + ) + False -> #(updated, effect.none()) + } +} + +/// Scan session state across a route change: a fresh entry into the +/// scan/review/done trio starts a blank session; moving within it (camera <-> +/// review) preserves rows; SCAN MORE (done -> scan) clears imported rows but +/// keeps unresolved ones. +fn scan_for_route( + scan: model.ScanState, + previous: model.Route, + route: model.Route, +) -> model.ScanState { + case route { + Scan -> + case previous { + ScanReview -> scan + ScanDone -> + model.ScanState( + ..scan, + rows: list.filter(scan.rows, fn(r) { r.outcome != model.Added }), + importing: False, + ) + _ -> model.blank_scan() + } + _ -> scan } } diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index db528b8..3e666ac 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -3,8 +3,8 @@ import at_record_web/model.{ type Entry, type Model, type Notice, type NoticeLevel, Add, Browse, Crate, - EditInbox, Failure, Info, LoggedIn, LoggedOut, Notice, Record, Scan, Settings, - Success, Warning, + EditInbox, Failure, Info, LoggedIn, LoggedOut, Notice, Record, Scan, ScanDone, + ScanReview, Settings, Success, Warning, } import at_record_web/msg.{type Msg, ClearNotice} import at_record_web/pages/add @@ -14,6 +14,8 @@ import at_record_web/pages/edit_inbox import at_record_web/pages/login import at_record_web/pages/record import at_record_web/pages/scan +import at_record_web/pages/scan_done +import at_record_web/pages/scan_review import at_record_web/pages/settings import at_record_web/route import at_record_web/ui/app_bar as bar @@ -44,6 +46,8 @@ fn page(model: Model) -> Element(Msg) { Crate -> crate.view(model) Add -> add.view(model) Scan -> scan.view(model) + ScanReview -> scan_review.view(model) + ScanDone -> scan_done.view(model) Browse -> browse.view(model) EditInbox -> edit_inbox.view(model) Settings -> settings.view(model) @@ -77,6 +81,8 @@ fn app_bar(model: Model, handle: String) -> Element(Msg) { bar.bar_title("SCAN BARCODE"), bar.icon_link("✎", "Add manually", route.to_path(Add)), ) + ScanReview -> back_bar_to("REVIEW YOUR HAUL", route.to_path(Scan)) + ScanDone -> back_bar("IMPORT COMPLETE") Browse -> back_bar("BROWSE") EditInbox -> back_bar("EDIT INBOX") Settings -> back_bar("SETTINGS") @@ -85,8 +91,12 @@ fn app_bar(model: Model, handle: String) -> Element(Msg) { } fn back_bar(title: String) -> Element(Msg) { + back_bar_to(title, "/") +} + +fn back_bar_to(title: String, href: String) -> Element(Msg) { bar.app_bar( - bar.icon_link("←", "Back", "/"), + bar.icon_link("←", "Back", href), bar.bar_title(title), bar.icon_disabled("⋯", "More actions"), )