diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 106ad62..7dd179b 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -1,1900 +1,140 @@ -import at_record/gen/feed/get_feed_skeleton.{type FeedItem} -import at_record_web/effects.{ - add_item, apply_edit_proposal, browse_add, copy_record_link, discogs_connect, - discogs_disconnect, discogs_import, discogs_import_wantlist, discogs_search, - discogs_status, entry_action, follow_user, handle_search, load_avatar, - load_browse, load_crate_overlap, load_entry, load_feed, load_pressing, - load_shelf, load_shelf_more, logout, oauth_login, rate, regrade, search_browse, - unfollow_user, -} -import at_record_web/model.{ - type BrowseRelease, type Entry, type EntryDetail, type Model, type Notice, - Actor, ActorCrate, Add, Browse, BrowseRelease, CrateOverlap, Discogs, - EditInbox, EditProposalDetail, Entry, EntryDetail, EntryDetailFailed, - EntryDetailLoaded, EntryDetailLoading, Failure, Feed, FeedFailed, FeedLoaded, - FeedLoading, Form, InboxLoaded, InboxLoading, LoggedIn, LoggedOut, Model, - Notice, Own, OwnCrate, PressingDetail, PressingFailed, PressingLoaded, - PressingLoading, ProposalApplied, ProposalApplying, ProposalReviewing, - PublicCrate, PublicRecord, Record, RecordAmend, Scan, ScanDone, ScanReview, - ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, blank_form, crate_of, - crate_window_size, set_crate, set_crate_loading_if_absent, -} -import at_record_web/money -import at_record_web/msg.{ - type Msg, type ScanLookup, AcceptSuggestion, AmendField, AppliedProposal, - ApplyProposal, ArmLogout, ArmRemove, ArtistSearch, Back, BarcodeDetected, - BatchItemDone, BrowseAdd, BrowseClearFilters, BrowseGenre, BrowseQuery, - CameraUnsupported, ClearNotice, ClearPhotoHint, CopyRecordLink, - CoverFileChosen, CoverUploaded, DisarmLogout, DisarmRemove, DiscogsConnect, - DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, DiscogsLoadMore, - DiscogsSearch, DiscogsVinylOnly, EntryAction, FeedShowMore, FormArtist, - FormCounterparty, FormFolder, FormFormat, FormPriceAmount, FormPriceCurrency, - FormRating, FormSleeveGrade, FormStatus, FormTitle, FormYear, GotAction, - GotActorShelf, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, - GotBrowse, GotBrowseAdd, GotBrowseSearch, GotCrateOverlap, GotDiscogs, - GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, - GotEntry, GotFeedEntry, GotFeedMore, GotFeedSkeleton, GotFollow, - GotHandleSuggestions, GotLogout, GotPressing, GotScanResult, GotScanSeen, - GotShelf, GotShelfMore, GotUnfollow, HandleChanged, HandleSearch, - IgnoreProposal, LinkCopied, Logout, OnRouteChange, PhotoCaptured, Rate, - Regrade, RemoveScanRow, RetryBatchItem, RetryShelf, SetDisplay, SetScanMode, - SetTheme, SetView, ShowMoreCrate, StartBatchImport, StartLogin, SubmitAdd, - SubmitAmend, TakePhoto, ToggleAmendCover, ToggleBrowseFilters, ToggleEdit, - ToggleFollow, TriggerBrowseSearch, UseArtist, UseDiscogs, UseHandleSuggestion, -} -import at_record_web/photo_scan -import at_record_web/route -import gleam/bool -import gleam/dict -import gleam/int -import gleam/list -import gleam/option.{type Option, None, Some} -import gleam/result -import gleam/set -import gleam/string +import at_record_web/model.{type Model} +import at_record_web/msg.{type Msg} +import at_record_web/update/add +import at_record_web/update/amend +import at_record_web/update/browse +import at_record_web/update/feed +import at_record_web/update/inbox +import at_record_web/update/routing +import at_record_web/update/scan +import at_record_web/update/session +import at_record_web/update/shelf import lustre/effect.{type Effect} -import modem -import rsvp +/// The Lustre update function: a thin dispatcher that routes each `Msg` +/// variant to the update module owning that feature. Domain modules never +/// import one another; a handoff that crosses domains (route changes seeding +/// an amend draft, a decoded photo rejoining the live scan path, ...) is +/// resolved here or inside `at_record_web/update/common`. pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { - OnRouteChange(route) -> on_route_change(model, route) - - // History when there's an in-app entry to land on; otherwise a direct - // push, so a direct-loaded drill-down's back control never navigates the - // visitor off the app entirely. - Back(fallback) -> - case model.nav_depth > 0 { - True -> #(model, modem.back(1)) - False -> #(model, modem.push(fallback, None, None)) - } - - ToggleEdit -> #( - Model(..model, editing: bool.negate(model.editing)), - effect.none(), - ) - - // Typing a handle searches the public directory after a pause, like the - // title field's Discogs autocomplete. - HandleChanged(value) -> { - let updated = Model(..model, login_handle: value, login_error: None) - case string.length(string.trim(value)) >= 2 { - True -> #(updated, effects.debounce("handle-search", 300, HandleSearch)) - False -> #(Model(..updated, login_suggestions: []), effect.none()) - } - } - HandleSearch -> #(model, handle_search(string.trim(model.login_handle))) - GotHandleSuggestions(found) -> #( - Model(..model, login_suggestions: result.unwrap(found, [])), - effect.none(), - ) - // Picking a suggestion is a confirmed, unambiguous handle: skip the extra - // "LOG IN" tap and start OAuth right away. - UseHandleSuggestion(suggestion) -> #( - Model( - ..model, - login_handle: suggestion.handle, - login_suggestions: [], - login_error: None, - notice: None, - ), - oauth_login(suggestion.handle), - ) - - StartLogin -> - case string.trim(model.login_handle) { - "" -> #(model, effect.none()) - handle -> #( - Model(..model, notice: None, login_error: None, login_suggestions: []), - oauth_login(handle), - ) - } - - ArmLogout -> #( - Model(..model, confirm_logout: True), - effects.arm_confirm( - model.logout_button_id, - "confirm-logout", - DisarmLogout, - ), - ) - DisarmLogout -> #( - Model(..model, confirm_logout: False), - effects.disarm_confirm(model.logout_button_id), - ) - - Logout -> #( - Model( - ..set_crate(model, OwnCrate, ShelfLoading), - auth: LoggedOut, - notice: None, - avatar: None, - confirm_logout: False, - ), - effect.batch([logout(), effects.disarm_confirm(model.logout_button_id)]), - ) - GotLogout -> #( - Model( - ..set_crate(model, OwnCrate, ShelfLoading), - auth: LoggedOut, - avatar: None, - ), - effect.none(), - ) - - // Switching filter view reloads the shelf and rewinds the window so the - // new view starts from the top rather than deep in the previous one. - // The stale cursor is dropped too: a cursor is scoped to the view it was - // minted under, so it wouldn't resume anywhere useful against a new one. - SetView(view) -> #( - Model( - ..set_crate(model, OwnCrate, ShelfLoading), - view:, - busy: True, - notice: None, - crate_window: crate_window_size, - crate_cursor: None, - shelf_loading_more: False, - ), - load_shelf(None, view), - ) - - SetDisplay(display) -> #( - Model(..model, display:), - effects.persist_display(display), - ) - - SetTheme(theme) -> #(Model(..model, theme:), effects.persist_theme(theme)) - - // Widening the window is free when enough is already fetched; only once - // the window catches up does this reach for the next page over the wire. - ShowMoreCrate -> { - let new_window = model.crate_window + crate_window_size - case new_window <= list.length(model.entries(model)) { - True -> #(Model(..model, crate_window: new_window), effect.none()) - False -> - case model.crate_cursor { - Some(cursor) -> #( - Model(..model, crate_window: new_window, shelf_loading_more: True), - load_shelf_more(model.view, cursor), - ) - None -> #(Model(..model, crate_window: new_window), effect.none()) - } - } - } - - // Re-run the shelf load after a failed fetch (or session restore). - RetryShelf -> #( - Model( - ..set_crate(model, OwnCrate, ShelfLoading), - busy: True, - notice: None, - crate_window: crate_window_size, - crate_cursor: None, - shelf_loading_more: False, - ), - load_shelf(None, model.view), - ) - - ClearNotice -> #(Model(..model, notice: None), effect.none()) - - GotShelf(Ok(data)) -> { - let updated = - Model( - ..set_crate(model, OwnCrate, ShelfLoaded(Own, data.items)), - auth: LoggedIn(data.handle), - via_handles: data.via_handles, - busy: False, - crate_window: crate_window_size, - crate_cursor: data.cursor, - ) - // A direct load of RecordAmend fires this shelf fetch itself (see - // `on_route_change`), since the draft couldn't be seeded from entries - // that weren't loaded yet. - let updated = case model.route { - RecordAmend(entry_id) -> - Model(..updated, amend: seed_amend_draft(updated, entry_id)) - _ -> updated - } - #( - updated, - effect.batch([ - case model.avatar { - None -> load_avatar(data.handle) - Some(_) -> effect.none() - }, - case model.route { - PublicCrate(handle) -> overlap_effect(LoggedIn(data.handle), handle) - _ -> effect.none() - }, - ]), - ) - } - - GotShelfMore(Ok(data)) -> { - let fresh = - model.append_new(model.entries(model), data.items, fn(e) { - Some(e.entry_id) - }) - let grown = ShelfLoaded(Own, list.append(model.entries(model), fresh)) - #( - Model( - ..set_crate(model, OwnCrate, grown), - via_handles: dict.merge(model.via_handles, data.via_handles), - crate_cursor: data.cursor, - shelf_loading_more: False, - ), - effect.none(), - ) - } - GotShelfMore(Error(_)) -> #( - Model( - ..model, - shelf_loading_more: False, - notice: failed("Couldn't load more of your shelf."), - ), - effect.none(), - ) - // Only 401 means logged-out; keep auth on transient errors (network, 5xx). - GotShelf(Error(rsvp.HttpError(resp))) -> - case resp.status { - 401 -> #( - Model( - ..set_crate(model, OwnCrate, ShelfLoaded(Own, [])), - auth: LoggedOut, - busy: False, - ), - effect.none(), - ) - _ -> #( - Model( - ..set_crate(model, OwnCrate, ShelfFailed), - busy: False, - notice: failed("Couldn't load your shelf (server error)."), - ), - effect.none(), - ) - } - GotShelf(Error(_)) -> #( - Model( - ..set_crate(model, OwnCrate, ShelfFailed), - busy: False, - notice: failed("Couldn't reach the server."), - ), - effect.none(), - ) - - // The title field doubles as Discogs autocomplete: typing searches after a pause. - FormTitle(value) -> { - let updated = - Model( - ..model, - form: Form(..model.form, title: value), - discogs: Discogs(..model.discogs, query: value), - ) - case string.length(string.trim(value)) >= 3 { - True -> #( - updated, - effects.debounce("discogs-search", 350, DiscogsSearch), - ) - False -> #( - Model(..updated, discogs: Discogs(..updated.discogs, results: [])), - effect.none(), - ) - } - } - // Free typing always clears any previously picked artist_id - the filter - // only re-locks when a suggestion is picked again via UseArtist. - FormArtist(value) -> { - let updated = - Model( - ..model, - form: Form(..model.form, artist: value), - discogs: Discogs( - ..model.discogs, - artist_query: value, - artist_id: None, - ), - ) - case string.length(string.trim(value)) >= 3 { - True -> #(updated, effects.debounce("artist-search", 350, ArtistSearch)) - False -> #( - Model( - ..updated, - discogs: Discogs(..updated.discogs, artist_results: []), - ), - effect.none(), - ) - } - } - FormFormat(value) -> #( - Model(..model, form: Form(..model.form, format: value)), - effect.none(), - ) - FormYear(value) -> #( - Model(..model, form: Form(..model.form, year: value)), - effect.none(), - ) - FormStatus(value) -> #( - Model(..model, form: Form(..model.form, status: value)), - effect.none(), - ) - FormSleeveGrade(value) -> #( - Model(..model, form: Form(..model.form, sleeve_grade: value)), - effect.none(), - ) - FormRating(value) -> #( - Model(..model, form: Form(..model.form, rating: value)), - effect.none(), - ) - FormFolder(value) -> #( - Model(..model, form: Form(..model.form, folder: value)), - effect.none(), - ) - FormPriceAmount(value) -> #( - Model(..model, form: Form(..model.form, price_amount: value)), - effect.none(), - ) - FormPriceCurrency(value) -> #( - Model(..model, form: Form(..model.form, price_currency: value)), - effect.none(), - ) - FormCounterparty(value) -> #( - Model(..model, form: Form(..model.form, counterparty: value)), - effect.none(), - ) - - SubmitAdd -> - case - string.trim(model.form.title), - string.trim(model.form.artist), - valid_price(model.form.price_amount) - { - "", _, _ | _, "", _ -> #( - Model(..model, notice: failed("Title and artist are required.")), - effect.none(), - ) - _, _, False -> #( - Model(..model, notice: failed("Enter a valid price amount.")), - effect.none(), - ) - _, _, True -> #( - Model(..model, busy: True, notice: None), - add_item(model.form), - ) - } - - GotAdd(Ok(Nil)) -> { - let title = string.trim(model.form.title) - #( - Model( - ..model, - form: blank_form(), - busy: True, - notice: succeeded("Added \"" <> title <> "\" to your crate."), - ), - load_shelf(None, model.view), - ) - } - GotAdd(Error(e)) -> write_error(model, e, "Could not save the record.") - - GotAvatar(Ok(maybe_url)) -> #( - Model(..model, avatar: maybe_url), - effect.none(), - ) - // Best-effort: the initial-letter fallback stays if the avatar fetch fails. - GotAvatar(Error(_)) -> #(model, effect.none()) - - ArmRemove -> #( - Model(..model, confirm_remove: True), - effects.arm_confirm( - model.remove_button_id, - "confirm-remove", - DisarmRemove, - ), - ) - DisarmRemove -> #( - Model(..model, confirm_remove: False), - effects.disarm_confirm(model.remove_button_id), - ) - - EntryAction(entry_id, action) -> #( - Model(..model, busy: True, confirm_remove: False), - effect.batch([ - entry_action(entry_id, action), - effects.disarm_confirm(model.remove_button_id), - ]), - ) - - // The open record's superset detail (own record page and public record - // page share the `entry_detail` slot; the routes are mutually exclusive). - GotEntry(Ok(data)) -> #( - Model( - ..model, - entry_detail: EntryDetailLoaded(detail_of(data)), - busy: False, - ), - effect.none(), - ) - GotEntry(Error(_)) -> #( - Model(..model, entry_detail: EntryDetailFailed, busy: False), - effect.none(), - ) - - // Rate/Regrade update the entry in place immediately (the select would - // otherwise sit on the old value, disabled, until a full reload lands) - // and keep the pre-edit entry so a failed write can restore it. - Regrade(_, "") -> #(model, effect.none()) - Regrade(entry_id, grade) -> { - let #(updated, backup) = - optimistic_patch(model, entry_id, fn(e) { - Entry(..e, media_grade: Some(grade)) - }) - #( - Model(..updated, busy: True, pending_revert: backup), - regrade(entry_id, grade), - ) - } - Rate(entry_id, rating) -> - case int.parse(rating) { - Ok(value) -> { - let #(updated, backup) = - optimistic_patch(model, entry_id, fn(e) { - Entry(..e, rating: Some(value)) - }) - #( - Model(..updated, busy: True, pending_revert: backup), - rate(entry_id, value), - ) - } - Error(_) -> #(model, effect.none()) - } - - // An optimistic Rate/Regrade is already reflected locally, so its - // success just clears the in-flight marker — no reload needed. Any - // other entry action (sold/dropped/moved, ...) can change which view an - // entry belongs in, so it still needs the full reload. - GotAction(Ok(Nil)) -> - case model.pending_revert { - Some(_) -> #( - Model(..model, busy: False, pending_revert: None), - effect.none(), - ) - None -> #(Model(..model, busy: True), after_write(model)) - } - GotAction(Error(e)) -> - case model.pending_revert { - Some(original) -> - write_error( - Model( - ..model, - entry_detail: set_detail_entry(model.entry_detail, original), - pending_revert: None, - ), - e, - "Could not update the entry.", - ) - None -> write_error(model, e, "Could not update the entry.") - } - - DiscogsSearch -> run_discogs_search(model, 1) - DiscogsVinylOnly(value) -> - run_discogs_search( - Model(..model, discogs: Discogs(..model.discogs, vinyl_only: value)), - 1, - ) - DiscogsLoadMore -> - case model.discogs.searching, model.discogs.page < model.discogs.pages { - False, True -> run_discogs_search(model, model.discogs.page + 1) - _, _ -> #(model, effect.none()) - } - GotDiscogs(Ok(found)) -> { - // Page 1 is a fresh search (replace); later pages are scroll loads (append). - let results = case found.page { - 1 -> found.results - _ -> list.append(model.discogs.results, found.results) - } - #( - Model( - ..model, - discogs: Discogs( - ..model.discogs, - results:, - page: found.page, - pages: found.pages, - searching: False, - ), - ), - effect.none(), - ) - } - GotDiscogs(Error(_)) -> #( - Model( - ..model, - discogs: Discogs(..model.discogs, searching: False), - notice: failed("Discogs search failed."), - ), - effect.none(), - ) - ArtistSearch -> run_artist_search(model, 1) - GotArtists(Ok(hits)) -> #( - Model(..model, discogs: Discogs(..model.discogs, artist_results: hits)), - effect.none(), - ) - // Transient failure: leave the typed artist name as-is, just no suggestions. - GotArtists(Error(_)) -> #(model, effect.none()) - UseArtist(hit) -> #( - Model( - ..model, - form: Form(..model.form, artist: hit.name), - discogs: Discogs( - ..model.discogs, - artist_query: hit.name, - artist_id: Some(hit.id), - artist_results: [], - ), - ), - effect.none(), - ) - GotDiscogsStatus(Ok(username)) -> #( - Model(..model, discogs: Discogs(..model.discogs, username:)), - effect.none(), - ) - // Transient failure: leave the connection state as-is. - GotDiscogsStatus(Error(_)) -> #(model, effect.none()) - - DiscogsConnect -> #(model, discogs_connect()) - DiscogsDisconnect -> #( - Model(..model, discogs: Discogs(..model.discogs, username: None)), - discogs_disconnect(), - ) - GotDiscogsDisconnect(_) -> #(model, effect.none()) - - DiscogsImport -> #( - Model( - ..model, - discogs: Discogs(..model.discogs, importing: True), - notice: None, - ), - discogs_import(), - ) - DiscogsImportWantlist -> #( - Model( - ..model, - discogs: Discogs(..model.discogs, importing: True), - notice: None, - ), - discogs_import_wantlist(), - ) - GotDiscogsImport(Ok(run)) -> #( - Model( - ..model, - discogs: Discogs(..model.discogs, importing: False), - busy: True, - notice: succeeded(import_summary(run)), - ), - load_shelf(None, model.view), - ) - GotDiscogsImport(Error(e)) -> - write_error( - Model(..model, discogs: Discogs(..model.discogs, importing: False)), - e, - "Import failed. Run it again to resume.", - ) - - AmendField(field, value) -> { - let d = model.amend - let d = case field { - "title" -> model.AmendDraft(..d, title: value) - "released" -> model.AmendDraft(..d, released: value) - "country" -> model.AmendDraft(..d, country: value) - "genres" -> model.AmendDraft(..d, genres: value) - _ -> model.AmendDraft(..d, styles: value) - } - #(Model(..model, amend: d), effect.none()) - } - ToggleAmendCover(value) -> #( - Model( - ..model, - amend: model.AmendDraft(..model.amend, refresh_cover: value), - ), - effect.none(), - ) - SubmitAmend -> - case model.selected { - Some(entry_id) -> #( - Model(..model, busy: True, publishing: True, notice: None), - effects.amend_entry(entry_id, model.amend), - ) - None -> #(model, effect.none()) - } - GotAmend(Ok(Nil)) -> #( - Model( - ..model, - busy: True, - publishing: False, - notice: succeeded("Published the amended catalog release."), - ), - effect.batch([after_write(model), back_to_record(model)]), - ) - GotAmend(Error(e)) -> { - let #(reverted, write_effect) = - write_error(model, e, "Could not publish the amendment.") - #(Model(..reverted, publishing: False), write_effect) - } - - CoverFileChosen -> - case model.selected { - Some(entry_id) -> #( - Model(..model, busy: True, notice: None), - effects.upload_cover(entry_id), - ) - None -> #(model, effect.none()) - } - CoverUploaded(True) -> #( - Model( - ..model, - busy: True, - notice: succeeded("Cover updated; published an amended release."), - ), - effect.batch([after_write(model), back_to_record(model)]), - ) - CoverUploaded(False) -> #( - Model(..model, busy: False, notice: failed("Cover upload failed.")), - effect.none(), - ) - - UseDiscogs(result) -> #( - Model( - ..model, - discogs: Discogs( - ..model.discogs, - results: [], - artist_id: None, - artist_results: [], - ), - form: model.form_from_discogs(result), - ), - effect.none(), - ) - - CameraUnsupported(reason) -> #( - Model( - ..model, - scan: model.ScanState(..model.scan, camera_error: Some(reason)), - ), - effect.none(), - ) - GotScanSeen(Ok(ids)) -> #( - Model(..model, scan: model.ScanState(..model.scan, seen_ids: ids)), - effect.none(), - ) - // A stale seen-ids set just means new matches won't show "already owned" until the next fetch. - GotScanSeen(Error(_)) -> #(model, effect.none()) - // The same code repeated back-to-back (still in frame, or a re-shot photo) - // is dropped outright; a different code always lands, even mid-import. - BarcodeDetected(barcode) -> - case Some(barcode) == model.scan.last_detected { - True -> #(model, effect.none()) - False -> { - let scan = model.ScanState(..model.scan, last_detected: Some(barcode)) - case list.any(scan.rows, fn(r) { r.barcode == barcode }) { - True -> #(Model(..model, scan:), effect.none()) - False -> #( - Model( - ..model, - scan: model.ScanState(..scan, rows: [ - model.ScanRow(barcode:, outcome: model.Pending), - ..scan.rows - ]), - ), - effects.scan_lookup(barcode), - ) - } - } - } - GotScanResult(barcode, Ok(lookup)) -> #( - Model( - ..model, - scan: update_row( - model.scan, - barcode, - scan_outcome(lookup, model.scan.seen_ids), - ), - ), - effect.none(), - ) - GotScanResult(barcode, Error(_)) -> #( - Model(..model, scan: update_row(model.scan, barcode, model.NoMatch([]))), - effect.none(), - ) - SetScanMode(mode) -> - case mode == model.scan.mode { - True -> #(model, effect.none()) - False -> #( - Model( - ..model, - scan: model.ScanState( - ..model.scan, - mode:, - photo_hint: None, - last_detected: None, - ), - ), - effect.batch([stop_capture(model.scan.mode), start_capture(mode)]), - ) - } - TakePhoto -> #( - Model(..model, scan: model.ScanState(..model.scan, photo_hint: None)), - photo_scan.capture_and_decode("scan-video", PhotoCaptured), - ) + msg.OnRouteChange(route) -> routing.on_route_change(model, route) + msg.Back(fallback) -> routing.back(model, fallback) + + msg.ToggleEdit -> shelf.toggle_edit(model) + msg.SetView(view) -> shelf.set_view(model, view) + msg.SetDisplay(display) -> shelf.set_display(model, display) + msg.ShowMoreCrate -> shelf.show_more_crate(model) + msg.GotShelf(result) -> shelf.got_shelf(model, result) + msg.GotShelfMore(result) -> shelf.got_shelf_more(model, result) + msg.RetryShelf -> shelf.retry_shelf(model) + msg.ClearNotice -> shelf.clear_notice(model) + msg.GotAvatar(result) -> shelf.got_avatar(model, result) + msg.ArmRemove -> shelf.arm_remove(model) + msg.DisarmRemove -> shelf.disarm_remove(model) + msg.EntryAction(entry_id, action) -> + shelf.entry_action_msg(model, entry_id, action) + msg.GotEntry(result) -> shelf.got_entry(model, result) + msg.Regrade(entry_id, grade) -> shelf.regrade(model, entry_id, grade) + msg.Rate(entry_id, rating) -> shelf.rate(model, entry_id, rating) + msg.GotAction(result) -> shelf.got_action(model, result) + msg.GotActorShelf(result) -> shelf.got_actor_shelf(model, result) + msg.CopyRecordLink(entry_id) -> shelf.copy_record_link_msg(model, entry_id) + msg.LinkCopied(ok) -> shelf.link_copied(model, ok) + + msg.HandleChanged(value) -> session.handle_changed(model, value) + msg.HandleSearch -> session.handle_search_msg(model) + msg.GotHandleSuggestions(found) -> + session.got_handle_suggestions(model, found) + msg.UseHandleSuggestion(suggestion) -> + session.use_handle_suggestion(model, suggestion) + msg.StartLogin -> session.start_login(model) + msg.ArmLogout -> session.arm_logout(model) + msg.DisarmLogout -> session.disarm_logout(model) + msg.Logout -> session.logout_msg(model) + msg.GotLogout -> session.got_logout(model) + msg.SetTheme(theme) -> session.set_theme(model, theme) + + msg.FormTitle(value) -> add.form_title(model, value) + msg.FormArtist(value) -> add.form_artist(model, value) + msg.FormFormat(value) -> add.form_format(model, value) + msg.FormYear(value) -> add.form_year(model, value) + msg.FormStatus(value) -> add.form_status(model, value) + msg.FormSleeveGrade(value) -> add.form_sleeve_grade(model, value) + msg.FormRating(value) -> add.form_rating(model, value) + msg.FormFolder(value) -> add.form_folder(model, value) + msg.FormPriceAmount(value) -> add.form_price_amount(model, value) + msg.FormPriceCurrency(value) -> add.form_price_currency(model, value) + msg.FormCounterparty(value) -> add.form_counterparty(model, value) + msg.SubmitAdd -> add.submit_add(model) + msg.GotAdd(result) -> add.got_add(model, result) + msg.DiscogsSearch -> add.discogs_search_msg(model, 1) + msg.DiscogsVinylOnly(value) -> add.discogs_vinyl_only(model, value) + msg.DiscogsLoadMore -> add.discogs_load_more(model) + msg.GotDiscogs(result) -> add.got_discogs(model, result) + msg.UseDiscogs(result) -> add.use_discogs(model, result) + msg.ArtistSearch -> add.artist_search_msg(model) + msg.GotArtists(result) -> add.got_artists(model, result) + msg.UseArtist(hit) -> add.use_artist(model, hit) + msg.GotDiscogsStatus(result) -> add.got_discogs_status(model, result) + msg.DiscogsConnect -> add.discogs_connect_msg(model) + msg.DiscogsDisconnect -> add.discogs_disconnect_msg(model) + msg.GotDiscogsDisconnect(_) -> add.got_discogs_disconnect(model) + msg.DiscogsImport -> add.discogs_import_msg(model) + msg.DiscogsImportWantlist -> add.discogs_import_wantlist_msg(model) + msg.GotDiscogsImport(result) -> add.got_discogs_import(model, result) + + msg.AmendField(field, value) -> amend.amend_field(model, field, value) + msg.ToggleAmendCover(value) -> amend.toggle_amend_cover(model, value) + msg.CoverFileChosen -> amend.cover_file_chosen(model) + msg.CoverUploaded(ok) -> amend.cover_uploaded(model, ok) + msg.SubmitAmend -> amend.submit_amend(model) + msg.GotAmend(result) -> amend.got_amend(model, result) + + msg.CameraUnsupported(reason) -> scan.camera_unsupported(model, reason) + msg.GotScanSeen(result) -> scan.got_scan_seen(model, result) + msg.BarcodeDetected(barcode) -> scan.barcode_detected(model, barcode) + msg.SetScanMode(mode) -> scan.set_scan_mode(model, mode) + msg.TakePhoto -> scan.take_photo(model) // A decoded shot rejoins the live path: same dedupe, same lookup effect. - PhotoCaptured(Ok(codes)) -> - case photo_scan.first_barcode(codes) { - Ok(barcode) -> update(model, BarcodeDetected(barcode)) - Error(Nil) -> #( - Model( - ..model, - scan: model.ScanState( - ..model.scan, - photo_hint: Some("No barcode in that shot. Try again."), - ), - ), - effects.debounce("photo-hint", 4000, ClearPhotoHint), - ) - } - PhotoCaptured(Error(photo_scan.Unsupported(_))) -> #( - Model( - ..model, - scan: model.ScanState( - ..model.scan, - mode: model.LiveMode, - photo_supported: False, - last_detected: None, - ), - ), - effects.start_scanner(), - ) - PhotoCaptured(Error(photo_scan.CaptureFailed(message))) - | PhotoCaptured(Error(photo_scan.DecodeFailed(message))) -> #( - Model(..model, notice: Some(Notice(Failure, message))), - effect.none(), - ) - ClearPhotoHint -> #( - Model(..model, scan: model.ScanState(..model.scan, photo_hint: None)), - effect.none(), - ) - // Fires no write: promotes the NoMatch row to a match so it imports with the batch. - AcceptSuggestion(barcode, release) -> - case find_row(model.scan, barcode) { - Some(model.ScanRow(outcome: model.NoMatch(_), ..)) -> #( - Model( - ..model, - scan: update_row( - model.scan, - barcode, - model.Matched(result: release, owned: False), - ), - ), - effect.none(), - ) - _ -> #(model, effect.none()) - } - // One write in flight at a time; BatchItemDone chains the rest. Double-tap guarded. - 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()) - } - BatchItemDone(barcode, Ok(Nil)) -> { - let scan = - model.ScanState( - ..update_row(model.scan, barcode, model.Added), - import_added: model.scan.import_added + 1, - ) - 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, model.Failed(item)) - _ -> model.scan - } - 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, - scan: model.ScanState( - ..model.scan, - rows: list.filter(model.scan.rows, fn(r) { r.barcode != barcode }), - ), - ), - effect.none(), - ) - - GotBrowse(Ok(rows)) -> #( - Model(..model, browse: rows, busy: False), - effect.none(), - ) - GotBrowse(Error(rsvp.HttpError(resp))) -> - case resp.status { - 401 -> #( - Model( - ..set_crate(model, OwnCrate, ShelfLoaded(Own, [])), - auth: LoggedOut, - busy: False, - ), - effect.none(), - ) - _ -> #( - Model( - ..model, - busy: False, - notice: failed("Couldn't load the shared catalog (server error)."), - ), - effect.none(), - ) - } - GotBrowse(Error(_)) -> #( - Model(..model, busy: False, notice: failed("Couldn't reach the server.")), - effect.none(), - ) - - // The search box doubles free-text/genre filtering over the same grid: - // typing searches after a pause, clearing both fields restores the - // default live grid instead of re-searching for "". - BrowseQuery(value) -> { - let updated = Model(..model, browse_query: value) - case string.trim(value), string.trim(updated.browse_genre) { - "", "" -> #(updated, load_browse()) - _, _ -> #( - updated, - effects.debounce("browse-search", 300, TriggerBrowseSearch), - ) - } - } - BrowseGenre(value) -> { - let updated = Model(..model, browse_genre: value) - case string.trim(updated.browse_query), string.trim(value) { - "", "" -> #(updated, load_browse()) - _, _ -> #( - updated, - effects.debounce("browse-search", 300, TriggerBrowseSearch), - ) - } - } - TriggerBrowseSearch -> #( - model, - search_browse(model.browse_query, model.browse_genre), - ) - GotBrowseSearch(Ok(rows)) -> #(Model(..model, browse: rows), effect.none()) - // A transient search failure keeps whatever's already on screen instead - // of wiping the grid out from under the viewer mid-search. - GotBrowseSearch(Error(_)) -> #( - Model(..model, notice: failed("Couldn't search the shared catalog.")), - effect.none(), - ) - ToggleBrowseFilters -> #( - Model(..model, browse_filters_open: !model.browse_filters_open), - effect.none(), - ) - BrowseClearFilters -> #( - Model(..model, browse_query: "", browse_genre: ""), - load_browse(), - ) - - // Guarded so only one browse add can be in flight at a time. - BrowseAdd(uri, cid, status) -> - case model.browse_adding { - Some(_) -> #(model, effect.none()) - None -> #( - Model(..model, browse_adding: Some(#(uri, status))), - browse_add(uri, cid, status), - ) - } - GotBrowseAdd(uri, Ok(Nil)) -> #( - Model( - ..model, - browse: mark_browse_row(model.browse, uri, model.browse_adding), - pressing: mark_pressing(model.pressing, uri, model.browse_adding), - browse_adding: None, - ), - effect.none(), - ) - GotBrowseAdd(_, Error(e)) -> - write_error( - Model(..model, browse_adding: None), - e, - "Could not save that record.", - ) - - GotPressing(Ok(row)) -> #( - Model(..model, pressing: PressingLoaded(row)), - effect.none(), - ) - GotPressing(Error(_)) -> #( - Model(..model, pressing: PressingFailed), - effect.none(), - ) - - // Proposals ignored on a previous visit (see IgnoreProposal) never make it into the loaded inbox at all. - GotEditInbox(Ok(proposals)) -> #( - Model( - ..model, - inbox: InboxLoaded( - proposals - |> list.filter(fn(p) { - !model.is_ignored_proposal(model.ignored_proposals, p.uri, p.cid) - }) - |> list.map(fn(p) { - model.InboxCard(proposal: p, status: ProposalReviewing) - }), - ), - busy: False, - ), - effect.none(), - ) - GotEditInbox(Error(rsvp.HttpError(resp))) -> - case resp.status { - 401 -> #( - Model( - ..set_crate(model, OwnCrate, ShelfLoaded(Own, [])), - auth: LoggedOut, - busy: False, - ), - effect.none(), - ) - _ -> #( - Model( - ..model, - inbox: model.InboxFailed, - busy: False, - notice: failed("Couldn't load your edit inbox (server error)."), - ), - effect.none(), - ) - } - GotEditInbox(Error(_)) -> #( - Model( - ..model, - inbox: model.InboxFailed, - busy: False, - notice: failed("Couldn't reach the server."), - ), - effect.none(), - ) - - ApplyProposal(uri, cid) -> #( - Model(..model, inbox: set_card_status(model.inbox, uri, ProposalApplying)), - apply_edit_proposal(uri, cid), - ) - // The card swaps for the "Applied: ..." strip; the shelf reloads so the - // superseding release shows up wherever it's referenced. - GotApplyProposal(uri, Ok(AppliedProposal(release_uri:))) -> #( - Model( - ..model, - inbox: set_card_status(model.inbox, uri, ProposalApplied(release_uri)), - ), - effect.batch([ - load_shelf(None, model.view), - leave_proposal_detail(model.route, uri), - ]), - ) - GotApplyProposal(uri, Error(e)) -> - write_error( - Model( - ..model, - inbox: set_card_status(model.inbox, uri, ProposalReviewing), - ), - e, - "Could not apply that proposal.", - ) - - // Dropped from the model right away; the uri+cid pair is also persisted - // (capped, JSON-encoded) so the proposal stays gone on the next visit - - // client-only, a repo record would make "ignored" public data. - IgnoreProposal(uri) -> { - let cid = find_proposal_cid(model.inbox, uri) |> option.unwrap("") - let ignored = - model.add_ignored_proposal(model.ignored_proposals, uri, cid) - #( - Model( - ..model, - inbox: remove_card(model.inbox, uri), - ignored_proposals: ignored, - ), - effect.batch([ - effects.persist_ignored_proposals(ignored), - leave_proposal_detail(model.route, uri), - ]), - ) - } - - GotActorShelf(Ok(data)) -> #( - set_crate( - model, - ActorCrate(data.handle), - ShelfLoaded(Actor(data.did, data.handle), data.items), - ), - effect.none(), - ) - // The response carries no handle to key off; fall back to whichever - // /u/:handle route is still open (a no-op if the visitor has since - // navigated away). - GotActorShelf(Error(_)) -> - case model.route { - PublicCrate(handle) -> #( - set_crate(model, ActorCrate(handle), ShelfFailed), - effect.none(), - ) - _ -> #(model, effect.none()) - } - - GotFeedSkeleton(Ok(data)) -> #( - Model(..model, feed: FeedLoaded(data.items, data.cursor, data.fallback)), - hydrate_uncached(data.items, model.feed_entries), - ) - GotFeedSkeleton(Error(_)) -> #( - Model( - ..model, - feed: FeedFailed, - notice: failed("Couldn't load the feed."), - ), - effect.none(), - ) - GotFeedEntry(actor, entry_id, Ok(data)) -> #( - Model( - ..model, - feed_entries: dict.insert( - model.feed_entries, - #(actor, entry_id), - detail_of(data), - ), - ), - effect.none(), - ) - // Per-row degrade: an unhydrated ref just keeps its skeleton placeholder, - // so a failed hydration is silently left uncached rather than crashing. - GotFeedEntry(_, _, Error(_)) -> #(model, effect.none()) - FeedShowMore -> - case model.feed { - FeedLoaded(_, Some(cursor), _) if !model.feed_loading_more -> #( - Model(..model, feed_loading_more: True), - load_feed(Some(cursor)), - ) - _ -> #(model, effect.none()) - } - GotFeedMore(Ok(data)) -> - case model.feed { - FeedLoaded(items, _, _) -> { - let fresh = model.append_new(items, data.items, model.feed_item_key) - #( - Model( - ..model, - feed: FeedLoaded( - list.append(items, fresh), - data.cursor, - data.fallback, - ), - feed_loading_more: False, - ), - hydrate_uncached(fresh, model.feed_entries), - ) - } - _ -> #(Model(..model, feed_loading_more: False), effect.none()) - } - GotFeedMore(Error(_)) -> #( - Model( - ..model, - feed_loading_more: False, - notice: failed("Couldn't load more of the feed."), - ), - effect.none(), - ) - - GotCrateOverlap(Ok(data)) -> #( - Model( - ..model, - overlap: Some(CrateOverlap( - common_count: data.common_count, - both_wanted_title: data.both_wanted_title, - want_match_count: data.want_match_count, - viewer_follows: data.viewer_follows, - follow_uri: data.follow_uri, - )), - ), - effect.none(), - ) - GotCrateOverlap(Error(_)) -> #(Model(..model, overlap: None), effect.none()) - - // The button only ever renders on a loaded public crate, but the match - // is total: no-op if either state isn't there yet. - ToggleFollow -> - case open_actor_shelf(model), model.overlap { - ShelfLoaded(Actor(did, _), _), Some(overlap) -> - case overlap.viewer_follows { - True -> #( - Model( - ..model, - overlap: Some( - CrateOverlap( - ..overlap, - viewer_follows: False, - follow_uri: None, - ), - ), - ), - unfollow_user(did), - ) - False -> #( - Model( - ..model, - overlap: Some(CrateOverlap(..overlap, viewer_follows: True)), - ), - follow_user(did), - ) - } - _, _ -> #(model, effect.none()) - } - GotFollow(Ok(follow_uri)) -> - case model.overlap { - Some(overlap) -> #( - Model( - ..model, - overlap: Some(CrateOverlap(..overlap, follow_uri: Some(follow_uri))), - ), - effect.none(), - ) - None -> #(model, effect.none()) - } - GotFollow(Error(e)) -> - case model.overlap { - Some(overlap) -> - write_error( - Model( - ..model, - overlap: Some( - CrateOverlap(..overlap, viewer_follows: False, follow_uri: None), - ), - ), - e, - "Could not follow that user.", - ) - None -> write_error(model, e, "Could not follow that user.") - } - // The optimistic clear in ToggleFollow already reflects the confirmed state. - GotUnfollow(Ok(Nil)) -> #(model, effect.none()) - GotUnfollow(Error(e)) -> - case model.overlap { - Some(overlap) -> - write_error( - Model( - ..model, - overlap: Some(CrateOverlap(..overlap, viewer_follows: True)), - ), - e, - "Could not unfollow that user.", - ) - None -> write_error(model, e, "Could not unfollow that user.") - } - - // Only signed-in visitors see the copy-link button (their own record - // page), so a logged-out call is unreachable in practice. - CopyRecordLink(entry_id) -> - case model.auth { - LoggedIn(handle) -> #( - model, - copy_record_link(route.to_path(PublicRecord(handle, entry_id))), - ) - LoggedOut -> #(model, effect.none()) - } - LinkCopied(True) -> #( - Model(..model, notice: succeeded("Link copied to clipboard.")), - effect.none(), - ) - LinkCopied(False) -> #( - Model(..model, notice: failed("Couldn't copy the link.")), - effect.none(), - ) - } -} - -/// Every route change lands here, so `nav_depth` (which `Back` reads) is -/// bumped in exactly one place regardless of which arm below handles the -/// new route. -fn on_route_change(model: Model, route: model.Route) -> #(Model, Effect(Msg)) { - let model = Model(..model, nav_depth: model.nav_depth + 1) - case route { - // Leaving a record clears the selection so the crate/add views start fresh. - Record(entry_id) -> #( - Model( - ..model, - route: Record(entry_id), - selected: Some(entry_id), - editing: False, - confirm_remove: False, - confirm_logout: False, - entry_detail: EntryDetailLoading, - busy: True, - ), - effect.batch([ - leaving_scan(model), - load_entry(None, entry_id, GotEntry), - disarm_confirms(), - ]), - ) - - // Seeds the draft from the entry on screen (title/year), same as the old - // fold-out's ToggleAmend used to; falls back to loading the shelf first - // when it isn't loaded yet (a direct load of this route), re-seeding once - // `GotShelf` lands (see that handler below). - RecordAmend(entry_id) -> { - let updated = - Model( - ..model, - route: RecordAmend(entry_id), - selected: Some(entry_id), - amend: seed_amend_draft(model, entry_id), - editing: False, - confirm_remove: False, - confirm_logout: False, - ) - let shelf_effect = case crate_of(model, OwnCrate) { - ShelfLoaded(_, _) -> effect.none() - _ -> load_shelf(None, model.view) - } - #( - updated, - effect.batch([leaving_scan(model), disarm_confirms(), shelf_effect]), - ) - } - - // Hydrates off `model.browse` when already loaded there; otherwise falls back to `catalog.getRelease`. - PressingDetail(did, rkey) -> { - let uri = model.release_uri(did, rkey) - let existing = list.find(model.browse, fn(row) { row.uri == uri }) - #( - Model( - ..model, - route: PressingDetail(did, rkey), - selected: None, - confirm_logout: False, - browse_adding: None, - pressing: case existing { - Ok(row) -> PressingLoaded(row) - Error(_) -> PressingLoading - }, - ), - effect.batch([ - leaving_scan(model), - disarm_confirms(), - case existing { - Ok(_) -> effect.none() - Error(_) -> load_pressing(uri) - }, - ]), - ) - } - - // Own arm: the done page must keep the run's old/added counts intact. - ScanDone -> { - let updated = - Model( - ..model, - route: ScanDone, - selected: None, - editing: False, - confirm_remove: False, - confirm_logout: False, - browse_adding: None, - ) - #(updated, effect.batch([leaving_scan(model), disarm_confirms()])) - } - - // 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. - route -> { - let scan = scan_for_route(model.scan, model.route, route) - let updated = - Model( - ..model, - route:, - selected: None, - editing: False, - confirm_remove: False, - confirm_logout: False, - browse_adding: None, - busy: case route { - Browse | EditInbox | EditProposalDetail(_) -> True - _ -> model.busy - }, - scan:, - inbox: case route { - EditInbox | EditProposalDetail(_) -> InboxLoading - _ -> model.inbox - }, - entry_detail: case route { - PublicRecord(_, _) -> EntryDetailLoading - _ -> model.entry_detail - }, - overlap: case route { - PublicCrate(_) -> None - _ -> model.overlap - }, - pressing: PressingLoading, - feed: case route { - Feed -> FeedLoading - _ -> model.feed - }, - ) - // A cached actor crate stays put (stale-while-revalidate): the fetch - // below still fires and refreshes it once it lands, but a fresh - // ShelfLoading only overwrites a key that's never been loaded. - let updated = case route { - PublicCrate(handle) -> - set_crate_loading_if_absent(updated, ActorCrate(handle)) - _ -> updated - } - #( - updated, - effect.batch([ - leaving_scan(model), - disarm_confirms(), - case route { - Add -> discogs_status() - Scan -> - effect.batch([start_capture(scan.mode), effects.scan_seen()]) - Browse -> load_browse() - Feed -> load_feed(None) - EditInbox | EditProposalDetail(_) -> effects.load_edit_inbox() - PublicCrate(handle) -> - effect.batch([ - load_shelf(Some(handle), "current"), - overlap_effect(model.auth, handle), - ]) - PublicRecord(handle, entry_id) -> - load_entry(Some(handle), entry_id, GotEntry) - _ -> effect.none() - }, - ]), - ) - } - } -} - -/// Seed an amend draft from the entry's snapshot (title + year as released, -/// rest blank), or a blank draft when the entry isn't loaded (yet). -fn seed_amend_draft(model: Model, entry_id: String) -> model.AmendDraft { - case list.find(model.entries(model), fn(e) { e.entry_id == entry_id }) { - Ok(found) -> - model.AmendDraft( - title: found.snapshot.title, - released: found.snapshot.year - |> option.map(int.to_string) - |> option.unwrap(""), - country: "", - genres: "", - styles: "", - refresh_cover: False, - ) - Error(Nil) -> model.blank_amend() - } -} - -/// After acting on a proposal from its own detail route, return to the -/// inbox list; a no-op when the action fired from the list itself (already -/// on `EditInbox`), so the list page's own apply/ignore isn't disrupted by a -/// redundant navigation and inbox refetch. -fn leave_proposal_detail( - current_route: model.Route, - uri: String, -) -> Effect(Msg) { - case current_route { - EditProposalDetail(id) -> - case model.proposal_route_id(uri) == id { - True -> modem.push(route.to_path(EditInbox), None, None) - False -> effect.none() - } - _ -> effect.none() - } -} - -/// Fan out hydration for every entry ref in a batch of feed items not -/// already sitting in the cache, so a re-fetched or overlapping item never -/// re-hydrates a ref that's already loaded. -fn hydrate_uncached( - items: List(FeedItem), - cache: dict.Dict(#(String, String), model.EntryDetail), -) -> Effect(Msg) { - items - |> list.flat_map(fn(item) { item.entries }) - |> list.filter(fn(ref) { !dict.has_key(cache, #(ref.actor, ref.entry_id)) }) - |> list.map(fn(ref) { - load_entry(Some(ref.actor), ref.entry_id, GotFeedEntry( - ref.actor, - ref.entry_id, - _, - )) - }) - |> effect.batch -} - -/// The one hydrated-entry payload the record pages and the feed cache share, -/// built from the `getEntry` response. -fn detail_of(data: msg.EntryDetailData) -> EntryDetail { - EntryDetail( - did: data.did, - handle: data.handle, - entry: data.entry, - events: data.events, - release_info: data.release, - ) -} - -fn find_proposal_cid(inbox: model.Inbox, uri: String) -> Option(String) { - case inbox { - InboxLoaded(cards) -> - cards - |> list.find(fn(c) { c.proposal.uri == uri }) - |> result.map(fn(c) { c.proposal.cid }) - |> option.from_result - _ -> None - } -} - -fn set_card_status( - inbox: model.Inbox, - uri: String, - status: model.ProposalStatus, -) -> model.Inbox { - case inbox { - InboxLoaded(cards) -> - InboxLoaded( - list.map(cards, fn(c) { - case c.proposal.uri == uri { - True -> model.InboxCard(..c, status:) - False -> c - } - }), - ) - other -> other - } -} - -fn remove_card(inbox: model.Inbox, uri: String) -> model.Inbox { - case inbox { - InboxLoaded(cards) -> - InboxLoaded(list.filter(cards, fn(c) { c.proposal.uri != uri })) - other -> other - } -} - -fn scan_outcome( - lookup: ScanLookup, - seen_ids: set.Set(String), -) -> model.ScanOutcome { - case lookup.network, lookup.result { - // A network hit wins outright: the server never even queried Discogs. - Some(release), _ -> model.NetworkMatched(release) - None, Some(result) -> - model.Matched( - result:, - owned: set.contains(seen_ids, int.to_string(result.discogs_id)), - ) - None, None -> model.NoMatch(lookup.suggestions) - } -} - -/// An unstarted row's item. `Failed` is excluded so the loop can't re-fire a -/// failure forever; that's RETRY's job. -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` (batch loop and RETRY share this). -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) - } -} - -/// Clear the running flag; any successes refresh the shelf and go to done. -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(None, model.view), - ]), - ) - False -> #(updated, effect.none()) - } -} - -/// A fresh entry into the scan/review/done trio starts a blank session; moving -/// within it preserves rows; done -> scan drops imported rows, keeps the rest. -fn scan_for_route( - scan: model.ScanState, - previous: model.Route, - route: model.Route, -) -> model.ScanState { - case route { - // Capture (re)starts unconditionally on entry, so the previous session's - // last-seen code must never carry over and jam the fresh detector loop. - 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() - } - |> fn(s) { model.ScanState(..s, last_detected: None) } - |> arm_capture_modes(previous) - _ -> scan - } -} - -/// Probed on every scan-page entry: a fresh session picks this browser's -/// default mode, while a return from review/done keeps the user's toggle. -fn arm_capture_modes( - scan: model.ScanState, - previous: model.Route, -) -> model.ScanState { - let photo = photo_scan.supports_photo_capture() - let mode = case previous { - ScanReview | ScanDone -> scan.mode - _ -> model.default_scan_mode(photo_scan.supports_live_detection(), photo) - } - model.ScanState(..scan, mode:, photo_supported: photo, photo_hint: None) -} - -fn start_capture(mode: model.ScanMode) -> Effect(Msg) { - case mode { - model.LiveMode -> effects.start_scanner() - model.PhotoMode -> photo_scan.start_camera("scan-video", CameraUnsupported) - } -} - -fn stop_capture(mode: model.ScanMode) -> Effect(Msg) { - case mode { - model.LiveMode -> effects.stop_scanner() - model.PhotoMode -> photo_scan.stop_camera() - } -} - -fn find_row(scan: model.ScanState, barcode: String) -> Option(model.ScanRow) { - list.find(scan.rows, fn(r) { r.barcode == barcode }) |> option.from_result -} - -fn update_row( - scan: model.ScanState, - barcode: String, - outcome: model.ScanOutcome, -) -> model.ScanState { - model.ScanState( - ..scan, - rows: list.map(scan.rows, fn(r) { - case r.barcode == barcode { - True -> model.ScanRow(..r, outcome:) - False -> r - } - }), - ) -} - -/// The camera must be released when navigating away from /scan, whichever -/// route comes next. -fn leaving_scan(model: Model) -> Effect(Msg) { - case model.route { - model.Scan -> stop_capture(model.scan.mode) - _ -> effect.none() - } -} - -/// The public crate cached for whichever `/u/:handle` route is currently -/// open, or `ShelfLoading` off that route entirely. -fn open_actor_shelf(model: Model) -> model.Shelf { - case model.route { - PublicCrate(handle) -> crate_of(model, ActorCrate(handle)) - _ -> ShelfLoading - } -} - -fn overlap_effect(auth: model.Auth, handle: String) -> Effect(Msg) { - case auth { - LoggedIn(viewer) if viewer != handle -> load_crate_overlap(handle) - _ -> effect.none() - } -} - -/// Both armed confirms reset on every route change; drop their outside-click -/// watchers too so a stale one never lingers on a button that just left the -/// page. -fn disarm_confirms() -> Effect(Msg) { - effect.batch([ - effects.disarm_confirm(model.remove_button_id), - effects.disarm_confirm(model.logout_button_id), - ]) -} - -/// Search Discogs for a page with the current query + vinyl filter, scoped by -/// the typed/picked artist, or no-op on an empty query. -fn run_discogs_search(model: Model, page: Int) -> #(Model, Effect(Msg)) { - case string.trim(model.discogs.query) { - "" -> #(model, effect.none()) - query -> #( - Model( - ..model, - discogs: Discogs(..model.discogs, searching: True), - notice: None, - ), - discogs_search( - query, - model.discogs.vinyl_only, - page, - artist_filter(model.form), - ), - ) - } -} - -fn artist_filter(form: model.Form) -> Option(String) { - case string.trim(form.artist) { - "" -> None - trimmed -> Some(trimmed) - } -} - -/// Search Discogs artists for the current typed query, or no-op on an empty one. -fn run_artist_search(model: Model, page: Int) -> #(Model, Effect(Msg)) { - case string.trim(model.discogs.artist_query) { - "" -> #(model, effect.none()) - query -> #(model, effects.discogs_search_artists(query, page)) - } -} - -fn import_summary(run: model.ImportRun) -> String { - let updated = case run.updated { - 0 -> "" - n -> " Updated " <> int.to_string(n) <> " with cover art." - } - let base = - "Imported " - <> int.to_string(run.imported) - <> ", skipped " - <> int.to_string(run.skipped) - <> " already in your crate." - <> updated - case run.note, run.done { - Some(note), _ -> base <> " " <> note <> "." - None, False -> base <> " Run again to continue." - None, True -> base - } -} - -/// Applies `patch` to the open record's entry right away (the record page -/// reads it off `entry_detail`), returning the pre-patch entry so the caller -/// can revert on a failed write. A no-op when the record isn't loaded. -fn optimistic_patch( - model: Model, - entry_id: String, - patch: fn(Entry) -> Entry, -) -> #(Model, Option(Entry)) { - case model.entry_detail { - EntryDetailLoaded(d) if d.entry.entry_id == entry_id -> #( - Model( - ..model, - entry_detail: EntryDetailLoaded(EntryDetail(..d, entry: patch(d.entry))), - ), - Some(d.entry), - ) - _ -> #(model, None) - } -} - -/// Restore an entry into `entry_detail` (revert of an optimistic patch). -fn set_detail_entry( - detail: model.EntryDetailState, - entry: Entry, -) -> model.EntryDetailState { - case detail { - EntryDetailLoaded(d) if d.entry.entry_id == entry.entry_id -> - EntryDetailLoaded(EntryDetail(..d, entry:)) - _ -> detail - } -} - -/// After an event lands, refresh the shelf and the open record detail (if any). -fn after_write(model: Model) -> Effect(Msg) { - case model.selected { - Some(entry_id) -> - effect.batch([ - load_shelf(None, model.view), - load_entry(None, entry_id, GotEntry), - ]) - None -> load_shelf(None, model.view) + msg.PhotoCaptured(result) -> scan.photo_captured(model, result) + msg.ClearPhotoHint -> scan.clear_photo_hint(model) + msg.GotScanResult(barcode, result) -> + scan.got_scan_result(model, barcode, result) + msg.RemoveScanRow(barcode) -> scan.remove_scan_row(model, barcode) + msg.AcceptSuggestion(barcode, release) -> + scan.accept_suggestion(model, barcode, release) + msg.StartBatchImport -> scan.start_batch_import(model) + msg.BatchItemDone(barcode, result) -> + scan.batch_item_done(model, barcode, result) + msg.RetryBatchItem(barcode) -> scan.retry_batch_item(model, barcode) + + msg.GotBrowse(result) -> browse.got_browse(model, result) + msg.BrowseQuery(value) -> browse.browse_query(model, value) + msg.BrowseGenre(value) -> browse.browse_genre(model, value) + msg.TriggerBrowseSearch -> browse.trigger_browse_search(model) + msg.GotBrowseSearch(result) -> browse.got_browse_search(model, result) + msg.ToggleBrowseFilters -> browse.toggle_browse_filters(model) + msg.BrowseClearFilters -> browse.browse_clear_filters(model) + msg.BrowseAdd(uri, cid, status) -> + browse.browse_add_msg(model, uri, cid, status) + msg.GotBrowseAdd(uri, result) -> browse.got_browse_add(model, uri, result) + msg.GotPressing(result) -> browse.got_pressing(model, result) + msg.GotCrateOverlap(result) -> browse.got_crate_overlap(model, result) + msg.ToggleFollow -> browse.toggle_follow(model) + msg.GotFollow(result) -> browse.got_follow(model, result) + msg.GotUnfollow(result) -> browse.got_unfollow(model, result) + + msg.GotEditInbox(result) -> inbox.got_edit_inbox(model, result) + msg.ApplyProposal(uri, cid) -> inbox.apply_proposal(model, uri, cid) + msg.GotApplyProposal(uri, result) -> + inbox.got_apply_proposal(model, uri, result) + msg.IgnoreProposal(uri) -> inbox.ignore_proposal(model, uri) + + msg.GotFeedSkeleton(result) -> feed.got_feed_skeleton(model, result) + msg.GotFeedEntry(actor, entry_id, result) -> + feed.got_feed_entry(model, actor, entry_id, result) + msg.FeedShowMore -> feed.feed_show_more(model) + msg.GotFeedMore(result) -> feed.got_feed_more(model, result) } } - -/// A published amend/cover-upload returns to the record it amended, the -/// panel-closing fold-out's old behaviour now expressed as a real navigation. -fn back_to_record(model: Model) -> Effect(Msg) { - case model.selected { - Some(entry_id) -> modem.push(route.to_path(Record(entry_id)), None, None) - None -> effect.none() - } -} - -/// Flip the owned/wanted flag on the browse row that was just written, -/// per the status carried in `browse_adding` (set when the write started). -fn mark_browse_row( - rows: List(BrowseRelease), - uri: String, - adding: Option(#(String, String)), -) -> List(BrowseRelease) { - case adding { - Some(#(adding_uri, status)) if adding_uri == uri -> - list.map(rows, fn(row) { - case row.uri == uri { - True -> - model.BrowseRelease( - ..row, - owned: row.owned || status == "owned", - wanted: row.wanted || status == "wanted", - ) - False -> row - } - }) - _ -> rows - } -} - -/// Same flip as `mark_browse_row`, for the pressing detail page's own copy -/// of the row (it isn't necessarily backed by `model.browse` at all). -fn mark_pressing( - pressing: model.PressingState, - uri: String, - adding: Option(#(String, String)), -) -> model.PressingState { - case pressing, adding { - PressingLoaded(row), Some(#(adding_uri, status)) - if adding_uri == uri && row.uri == uri - -> - PressingLoaded( - BrowseRelease( - ..row, - owned: row.owned || status == "owned", - wanted: row.wanted || status == "wanted", - ), - ) - _, _ -> pressing - } -} - -fn write_error( - model: Model, - error: rsvp.Error(String), - fallback: String, -) -> #(Model, Effect(Msg)) { - case error { - rsvp.HttpError(resp) if resp.status == 401 -> #( - Model( - ..set_crate(model, OwnCrate, ShelfLoaded(Own, [])), - auth: LoggedOut, - busy: False, - notice: Some(Notice( - Warning, - "Your session expired. Please sign in again.", - )), - ), - effect.none(), - ) - _ -> #(Model(..model, busy: False, notice: failed(fallback)), effect.none()) - } -} - -/// An empty amount is fine (price is optional); a non-empty one must parse. -fn valid_price(amount: String) -> Bool { - case string.trim(amount) { - "" -> True - trimmed -> result.is_ok(money.parse_minor_units(trimmed)) - } -} - -fn succeeded(message: String) -> Option(Notice) { - Some(Notice(Success, message)) -} - -fn failed(message: String) -> Option(Notice) { - Some(Notice(Failure, message)) -} diff --git a/web/src/at_record_web/update/add.gleam b/web/src/at_record_web/update/add.gleam new file mode 100644 index 0000000..639608b --- /dev/null +++ b/web/src/at_record_web/update/add.gleam @@ -0,0 +1,390 @@ +import at_record_web/effects.{ + add_item, discogs_connect, discogs_disconnect, discogs_import, + discogs_import_wantlist, discogs_search, load_shelf, +} +import at_record_web/model.{ + type ArtistHit, type DiscogsResult, type DiscogsSearchPage, type ImportRun, + type Model, Discogs, Form, Model, blank_form, +} +import at_record_web/money +import at_record_web/msg.{type Msg, ArtistSearch, DiscogsSearch} +import at_record_web/update/common.{failed, succeeded, write_error} +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import lustre/effect.{type Effect} +import rsvp + +/// The title field doubles as Discogs autocomplete: typing searches after a pause. +pub fn form_title(model: Model, value: String) -> #(Model, Effect(Msg)) { + let updated = + Model( + ..model, + form: Form(..model.form, title: value), + discogs: Discogs(..model.discogs, query: value), + ) + case string.length(string.trim(value)) >= 3 { + True -> #(updated, effects.debounce("discogs-search", 350, DiscogsSearch)) + False -> #( + Model(..updated, discogs: Discogs(..updated.discogs, results: [])), + effect.none(), + ) + } +} + +/// Free typing always clears any previously picked artist_id - the filter +/// only re-locks when a suggestion is picked again via UseArtist. +pub fn form_artist(model: Model, value: String) -> #(Model, Effect(Msg)) { + let updated = + Model( + ..model, + form: Form(..model.form, artist: value), + discogs: Discogs(..model.discogs, artist_query: value, artist_id: None), + ) + case string.length(string.trim(value)) >= 3 { + True -> #(updated, effects.debounce("artist-search", 350, ArtistSearch)) + False -> #( + Model(..updated, discogs: Discogs(..updated.discogs, artist_results: [])), + effect.none(), + ) + } +} + +pub fn form_format(model: Model, value: String) -> #(Model, Effect(Msg)) { + #(Model(..model, form: Form(..model.form, format: value)), effect.none()) +} + +pub fn form_year(model: Model, value: String) -> #(Model, Effect(Msg)) { + #(Model(..model, form: Form(..model.form, year: value)), effect.none()) +} + +pub fn form_status(model: Model, value: String) -> #(Model, Effect(Msg)) { + #(Model(..model, form: Form(..model.form, status: value)), effect.none()) +} + +pub fn form_sleeve_grade(model: Model, value: String) -> #(Model, Effect(Msg)) { + #( + Model(..model, form: Form(..model.form, sleeve_grade: value)), + effect.none(), + ) +} + +pub fn form_rating(model: Model, value: String) -> #(Model, Effect(Msg)) { + #(Model(..model, form: Form(..model.form, rating: value)), effect.none()) +} + +pub fn form_folder(model: Model, value: String) -> #(Model, Effect(Msg)) { + #(Model(..model, form: Form(..model.form, folder: value)), effect.none()) +} + +pub fn form_price_amount(model: Model, value: String) -> #(Model, Effect(Msg)) { + #( + Model(..model, form: Form(..model.form, price_amount: value)), + effect.none(), + ) +} + +pub fn form_price_currency( + model: Model, + value: String, +) -> #(Model, Effect(Msg)) { + #( + Model(..model, form: Form(..model.form, price_currency: value)), + effect.none(), + ) +} + +pub fn form_counterparty(model: Model, value: String) -> #(Model, Effect(Msg)) { + #( + Model(..model, form: Form(..model.form, counterparty: value)), + effect.none(), + ) +} + +pub fn submit_add(model: Model) -> #(Model, Effect(Msg)) { + case + string.trim(model.form.title), + string.trim(model.form.artist), + valid_price(model.form.price_amount) + { + "", _, _ | _, "", _ -> #( + Model(..model, notice: failed("Title and artist are required.")), + effect.none(), + ) + _, _, False -> #( + Model(..model, notice: failed("Enter a valid price amount.")), + effect.none(), + ) + _, _, True -> #( + Model(..model, busy: True, notice: None), + add_item(model.form), + ) + } +} + +pub fn got_add( + model: Model, + result: Result(Nil, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(Nil) -> { + let title = string.trim(model.form.title) + #( + Model( + ..model, + form: blank_form(), + busy: True, + notice: succeeded("Added \"" <> title <> "\" to your crate."), + ), + load_shelf(None, model.view), + ) + } + Error(e) -> write_error(model, e, "Could not save the record.") + } +} + +pub fn discogs_search_msg(model: Model, page: Int) -> #(Model, Effect(Msg)) { + run_discogs_search(model, page) +} + +pub fn discogs_vinyl_only(model: Model, value: Bool) -> #(Model, Effect(Msg)) { + run_discogs_search( + Model(..model, discogs: Discogs(..model.discogs, vinyl_only: value)), + 1, + ) +} + +pub fn discogs_load_more(model: Model) -> #(Model, Effect(Msg)) { + case model.discogs.searching, model.discogs.page < model.discogs.pages { + False, True -> run_discogs_search(model, model.discogs.page + 1) + _, _ -> #(model, effect.none()) + } +} + +pub fn got_discogs( + model: Model, + result: Result(DiscogsSearchPage, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + // Page 1 is a fresh search (replace); later pages are scroll loads (append). + Ok(found) -> { + let results = case found.page { + 1 -> found.results + _ -> list.append(model.discogs.results, found.results) + } + #( + Model( + ..model, + discogs: Discogs( + ..model.discogs, + results:, + page: found.page, + pages: found.pages, + searching: False, + ), + ), + effect.none(), + ) + } + Error(_) -> #( + Model( + ..model, + discogs: Discogs(..model.discogs, searching: False), + notice: failed("Discogs search failed."), + ), + effect.none(), + ) + } +} + +pub fn artist_search_msg(model: Model) -> #(Model, Effect(Msg)) { + run_artist_search(model, 1) +} + +pub fn got_artists( + model: Model, + result: Result(List(ArtistHit), rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(hits) -> #( + Model(..model, discogs: Discogs(..model.discogs, artist_results: hits)), + effect.none(), + ) + // Transient failure: leave the typed artist name as-is, just no suggestions. + Error(_) -> #(model, effect.none()) + } +} + +pub fn use_artist(model: Model, hit: ArtistHit) -> #(Model, Effect(Msg)) { + #( + Model( + ..model, + form: Form(..model.form, artist: hit.name), + discogs: Discogs( + ..model.discogs, + artist_query: hit.name, + artist_id: Some(hit.id), + artist_results: [], + ), + ), + effect.none(), + ) +} + +pub fn got_discogs_status( + model: Model, + result: Result(Option(String), rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(username) -> #( + Model(..model, discogs: Discogs(..model.discogs, username:)), + effect.none(), + ) + // Transient failure: leave the connection state as-is. + Error(_) -> #(model, effect.none()) + } +} + +pub fn discogs_connect_msg(model: Model) -> #(Model, Effect(Msg)) { + #(model, discogs_connect()) +} + +pub fn discogs_disconnect_msg(model: Model) -> #(Model, Effect(Msg)) { + #( + Model(..model, discogs: Discogs(..model.discogs, username: None)), + discogs_disconnect(), + ) +} + +pub fn got_discogs_disconnect(model: Model) -> #(Model, Effect(Msg)) { + #(model, effect.none()) +} + +pub fn discogs_import_msg(model: Model) -> #(Model, Effect(Msg)) { + #( + Model( + ..model, + discogs: Discogs(..model.discogs, importing: True), + notice: None, + ), + discogs_import(), + ) +} + +pub fn discogs_import_wantlist_msg(model: Model) -> #(Model, Effect(Msg)) { + #( + Model( + ..model, + discogs: Discogs(..model.discogs, importing: True), + notice: None, + ), + discogs_import_wantlist(), + ) +} + +pub fn got_discogs_import( + model: Model, + result: Result(ImportRun, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(run) -> #( + Model( + ..model, + discogs: Discogs(..model.discogs, importing: False), + busy: True, + notice: succeeded(import_summary(run)), + ), + load_shelf(None, model.view), + ) + Error(e) -> + write_error( + Model(..model, discogs: Discogs(..model.discogs, importing: False)), + e, + "Import failed. Run it again to resume.", + ) + } +} + +pub fn use_discogs( + model: Model, + result: DiscogsResult, +) -> #(Model, Effect(Msg)) { + #( + Model( + ..model, + discogs: Discogs( + ..model.discogs, + results: [], + artist_id: None, + artist_results: [], + ), + form: model.form_from_discogs(result), + ), + effect.none(), + ) +} + +/// Search Discogs for a page with the current query + vinyl filter, scoped by +/// the typed/picked artist, or no-op on an empty query. +fn run_discogs_search(model: Model, page: Int) -> #(Model, Effect(Msg)) { + case string.trim(model.discogs.query) { + "" -> #(model, effect.none()) + query -> #( + Model( + ..model, + discogs: Discogs(..model.discogs, searching: True), + notice: None, + ), + discogs_search( + query, + model.discogs.vinyl_only, + page, + artist_filter(model.form), + ), + ) + } +} + +fn artist_filter(form: model.Form) -> Option(String) { + case string.trim(form.artist) { + "" -> None + trimmed -> Some(trimmed) + } +} + +/// Search Discogs artists for the current typed query, or no-op on an empty one. +fn run_artist_search(model: Model, page: Int) -> #(Model, Effect(Msg)) { + case string.trim(model.discogs.artist_query) { + "" -> #(model, effect.none()) + query -> #(model, effects.discogs_search_artists(query, page)) + } +} + +fn import_summary(run: model.ImportRun) -> String { + let updated = case run.updated { + 0 -> "" + n -> " Updated " <> int.to_string(n) <> " with cover art." + } + let base = + "Imported " + <> int.to_string(run.imported) + <> ", skipped " + <> int.to_string(run.skipped) + <> " already in your crate." + <> updated + case run.note, run.done { + Some(note), _ -> base <> " " <> note <> "." + None, False -> base <> " Run again to continue." + None, True -> base + } +} + +/// An empty amount is fine (price is optional); a non-empty one must parse. +fn valid_price(amount: String) -> Bool { + case string.trim(amount) { + "" -> True + trimmed -> result.is_ok(money.parse_minor_units(trimmed)) + } +} diff --git a/web/src/at_record_web/update/amend.gleam b/web/src/at_record_web/update/amend.gleam new file mode 100644 index 0000000..d792dca --- /dev/null +++ b/web/src/at_record_web/update/amend.gleam @@ -0,0 +1,101 @@ +import at_record_web/effects +import at_record_web/model.{type Model, AmendDraft, Model} +import at_record_web/msg.{type Msg} +import at_record_web/route +import at_record_web/update/common.{after_write, failed, succeeded, write_error} +import gleam/option.{None, Some} +import lustre/effect.{type Effect} +import modem +import rsvp + +pub fn amend_field( + model: Model, + field: String, + value: String, +) -> #(Model, Effect(Msg)) { + let d = model.amend + let d = case field { + "title" -> AmendDraft(..d, title: value) + "released" -> AmendDraft(..d, released: value) + "country" -> AmendDraft(..d, country: value) + "genres" -> AmendDraft(..d, genres: value) + _ -> AmendDraft(..d, styles: value) + } + #(Model(..model, amend: d), effect.none()) +} + +pub fn toggle_amend_cover(model: Model, value: Bool) -> #(Model, Effect(Msg)) { + #( + Model(..model, amend: AmendDraft(..model.amend, refresh_cover: value)), + effect.none(), + ) +} + +pub fn submit_amend(model: Model) -> #(Model, Effect(Msg)) { + case model.selected { + Some(entry_id) -> #( + Model(..model, busy: True, publishing: True, notice: None), + effects.amend_entry(entry_id, model.amend), + ) + None -> #(model, effect.none()) + } +} + +pub fn got_amend( + model: Model, + result: Result(Nil, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(Nil) -> #( + Model( + ..model, + busy: True, + publishing: False, + notice: succeeded("Published the amended catalog release."), + ), + effect.batch([after_write(model), back_to_record(model)]), + ) + Error(e) -> { + let #(reverted, write_effect) = + write_error(model, e, "Could not publish the amendment.") + #(Model(..reverted, publishing: False), write_effect) + } + } +} + +pub fn cover_file_chosen(model: Model) -> #(Model, Effect(Msg)) { + case model.selected { + Some(entry_id) -> #( + Model(..model, busy: True, notice: None), + effects.upload_cover(entry_id), + ) + None -> #(model, effect.none()) + } +} + +pub fn cover_uploaded(model: Model, ok: Bool) -> #(Model, Effect(Msg)) { + case ok { + True -> #( + Model( + ..model, + busy: True, + notice: succeeded("Cover updated; published an amended release."), + ), + effect.batch([after_write(model), back_to_record(model)]), + ) + False -> #( + Model(..model, busy: False, notice: failed("Cover upload failed.")), + effect.none(), + ) + } +} + +/// A published amend/cover-upload returns to the record it amended, the +/// panel-closing fold-out's old behaviour now expressed as a real navigation. +fn back_to_record(model: Model) -> Effect(Msg) { + case model.selected { + Some(entry_id) -> + modem.push(route.to_path(model.Record(entry_id)), None, None) + None -> effect.none() + } +} diff --git a/web/src/at_record_web/update/browse.gleam b/web/src/at_record_web/update/browse.gleam new file mode 100644 index 0000000..76da318 --- /dev/null +++ b/web/src/at_record_web/update/browse.gleam @@ -0,0 +1,321 @@ +import at_record_web/effects.{ + browse_add, follow_user, load_browse, load_crate_overlap, search_browse, + unfollow_user, +} +import at_record_web/model.{ + type BrowseRelease, type Model, BrowseRelease, CrateOverlap, LoggedIn, + LoggedOut, Model, Own, OwnCrate, PressingFailed, PressingLoaded, ShelfLoaded, +} +import at_record_web/msg.{type Msg} +import at_record_web/update/common.{failed, write_error} +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import lustre/effect.{type Effect} +import rsvp + +pub fn got_browse( + model: Model, + result: Result(List(BrowseRelease), rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(rows) -> #(Model(..model, browse: rows, busy: False), effect.none()) + // Only 401 means logged-out; keep auth on transient errors (network, 5xx). + Error(rsvp.HttpError(resp)) -> + case resp.status { + 401 -> #( + Model( + ..model.set_crate(model, OwnCrate, ShelfLoaded(Own, [])), + auth: LoggedOut, + busy: False, + ), + effect.none(), + ) + _ -> #( + Model( + ..model, + busy: False, + notice: failed("Couldn't load the shared catalog (server error)."), + ), + effect.none(), + ) + } + Error(_) -> #( + Model(..model, busy: False, notice: failed("Couldn't reach the server.")), + effect.none(), + ) + } +} + +/// The search box doubles free-text/genre filtering over the same grid: +/// typing searches after a pause, clearing both fields restores the +/// default live grid instead of re-searching for "". +pub fn browse_query(model: Model, value: String) -> #(Model, Effect(Msg)) { + let updated = Model(..model, browse_query: value) + case string.trim(value), string.trim(updated.browse_genre) { + "", "" -> #(updated, load_browse()) + _, _ -> #( + updated, + effects.debounce("browse-search", 300, msg.TriggerBrowseSearch), + ) + } +} + +pub fn browse_genre(model: Model, value: String) -> #(Model, Effect(Msg)) { + let updated = Model(..model, browse_genre: value) + case string.trim(updated.browse_query), string.trim(value) { + "", "" -> #(updated, load_browse()) + _, _ -> #( + updated, + effects.debounce("browse-search", 300, msg.TriggerBrowseSearch), + ) + } +} + +pub fn trigger_browse_search(model: Model) -> #(Model, Effect(Msg)) { + #(model, search_browse(model.browse_query, model.browse_genre)) +} + +pub fn got_browse_search( + model: Model, + result: Result(List(BrowseRelease), rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(rows) -> #(Model(..model, browse: rows), effect.none()) + // A transient search failure keeps whatever's already on screen instead + // of wiping the grid out from under the viewer mid-search. + Error(_) -> #( + Model(..model, notice: failed("Couldn't search the shared catalog.")), + effect.none(), + ) + } +} + +pub fn toggle_browse_filters(model: Model) -> #(Model, Effect(Msg)) { + #( + Model(..model, browse_filters_open: !model.browse_filters_open), + effect.none(), + ) +} + +pub fn browse_clear_filters(model: Model) -> #(Model, Effect(Msg)) { + #(Model(..model, browse_query: "", browse_genre: ""), load_browse()) +} + +/// Guarded so only one browse add can be in flight at a time. +pub fn browse_add_msg( + model: Model, + uri: String, + cid: String, + status: String, +) -> #(Model, Effect(Msg)) { + case model.browse_adding { + Some(_) -> #(model, effect.none()) + None -> #( + Model(..model, browse_adding: Some(#(uri, status))), + browse_add(uri, cid, status), + ) + } +} + +pub fn got_browse_add( + model: Model, + uri: String, + result: Result(Nil, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(Nil) -> #( + Model( + ..model, + browse: mark_browse_row(model.browse, uri, model.browse_adding), + pressing: mark_pressing(model.pressing, uri, model.browse_adding), + browse_adding: None, + ), + effect.none(), + ) + Error(e) -> + write_error( + Model(..model, browse_adding: None), + e, + "Could not save that record.", + ) + } +} + +pub fn got_pressing( + model: Model, + result: Result(BrowseRelease, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(row) -> #(Model(..model, pressing: PressingLoaded(row)), effect.none()) + Error(_) -> #(Model(..model, pressing: PressingFailed), effect.none()) + } +} + +pub fn got_crate_overlap( + model: Model, + result: Result(msg.CrateOverlapData, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(data) -> #( + Model( + ..model, + overlap: Some(CrateOverlap( + common_count: data.common_count, + both_wanted_title: data.both_wanted_title, + want_match_count: data.want_match_count, + viewer_follows: data.viewer_follows, + follow_uri: data.follow_uri, + )), + ), + effect.none(), + ) + Error(_) -> #(Model(..model, overlap: None), effect.none()) + } +} + +/// The button only ever renders on a loaded public crate, but the match +/// is total: no-op if either state isn't there yet. +pub fn toggle_follow(model: Model) -> #(Model, Effect(Msg)) { + case open_actor_shelf(model), model.overlap { + ShelfLoaded(model.Actor(did, _), _), Some(overlap) -> + case overlap.viewer_follows { + True -> #( + Model( + ..model, + overlap: Some( + CrateOverlap(..overlap, viewer_follows: False, follow_uri: None), + ), + ), + unfollow_user(did), + ) + False -> #( + Model( + ..model, + overlap: Some(CrateOverlap(..overlap, viewer_follows: True)), + ), + follow_user(did), + ) + } + _, _ -> #(model, effect.none()) + } +} + +pub fn got_follow( + model: Model, + result: Result(String, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(follow_uri) -> + case model.overlap { + Some(overlap) -> #( + Model( + ..model, + overlap: Some(CrateOverlap(..overlap, follow_uri: Some(follow_uri))), + ), + effect.none(), + ) + None -> #(model, effect.none()) + } + Error(e) -> + case model.overlap { + Some(overlap) -> + write_error( + Model( + ..model, + overlap: Some( + CrateOverlap(..overlap, viewer_follows: False, follow_uri: None), + ), + ), + e, + "Could not follow that user.", + ) + None -> write_error(model, e, "Could not follow that user.") + } + } +} + +/// The optimistic clear in ToggleFollow already reflects the confirmed state. +pub fn got_unfollow( + model: Model, + result: Result(Nil, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(Nil) -> #(model, effect.none()) + Error(e) -> + case model.overlap { + Some(overlap) -> + write_error( + Model( + ..model, + overlap: Some(CrateOverlap(..overlap, viewer_follows: True)), + ), + e, + "Could not unfollow that user.", + ) + None -> write_error(model, e, "Could not unfollow that user.") + } + } +} + +/// Flip the owned/wanted flag on the browse row that was just written, +/// per the status carried in `browse_adding` (set when the write started). +fn mark_browse_row( + rows: List(BrowseRelease), + uri: String, + adding: Option(#(String, String)), +) -> List(BrowseRelease) { + case adding { + Some(#(adding_uri, status)) if adding_uri == uri -> + list.map(rows, fn(row) { + case row.uri == uri { + True -> + BrowseRelease( + ..row, + owned: row.owned || status == "owned", + wanted: row.wanted || status == "wanted", + ) + False -> row + } + }) + _ -> rows + } +} + +/// Same flip as `mark_browse_row`, for the pressing detail page's own copy +/// of the row (it isn't necessarily backed by `model.browse` at all). +fn mark_pressing( + pressing: model.PressingState, + uri: String, + adding: Option(#(String, String)), +) -> model.PressingState { + case pressing, adding { + PressingLoaded(row), Some(#(adding_uri, status)) + if adding_uri == uri && row.uri == uri + -> + PressingLoaded( + BrowseRelease( + ..row, + owned: row.owned || status == "owned", + wanted: row.wanted || status == "wanted", + ), + ) + _, _ -> pressing + } +} + +/// The public crate cached for whichever `/u/:handle` route is currently +/// open, or `ShelfLoading` off that route entirely. +pub fn open_actor_shelf(model: Model) -> model.Shelf { + case model.route { + model.PublicCrate(handle) -> model.crate_of(model, model.ActorCrate(handle)) + _ -> model.ShelfLoading + } +} + +pub fn overlap_effect(auth: model.Auth, handle: String) -> Effect(Msg) { + case auth { + LoggedIn(viewer) if viewer != handle -> load_crate_overlap(handle) + _ -> effect.none() + } +} diff --git a/web/src/at_record_web/update/common.gleam b/web/src/at_record_web/update/common.gleam new file mode 100644 index 0000000..50989fd --- /dev/null +++ b/web/src/at_record_web/update/common.gleam @@ -0,0 +1,89 @@ +import at_record_web/effects.{load_entry, load_shelf} +import at_record_web/model.{ + type EntryDetail, type Model, type Notice, EntryDetail, Failure, LoggedOut, + Model, Notice, OwnCrate, ShelfLoaded, Success, Warning, blank_amend, set_crate, +} +import at_record_web/msg.{type Msg, GotEntry} +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import lustre/effect.{type Effect} +import rsvp + +/// A success notice. +pub fn succeeded(message: String) -> Option(Notice) { + Some(Notice(Success, message)) +} + +/// A failure notice. +pub fn failed(message: String) -> Option(Notice) { + Some(Notice(Failure, message)) +} + +/// The shared 401-vs-everything-else handling for a failed write: a session +/// expiry signs the visitor out and clears their shelf, anything else just +/// surfaces `fallback` as a failure notice. +pub fn write_error( + model: Model, + error: rsvp.Error(String), + fallback: String, +) -> #(Model, Effect(Msg)) { + case error { + rsvp.HttpError(resp) if resp.status == 401 -> #( + Model( + ..set_crate(model, OwnCrate, ShelfLoaded(model.Own, [])), + auth: LoggedOut, + busy: False, + notice: Some(Notice( + Warning, + "Your session expired. Please sign in again.", + )), + ), + effect.none(), + ) + _ -> #(Model(..model, busy: False, notice: failed(fallback)), effect.none()) + } +} + +/// The one hydrated-entry payload the record pages and the feed cache share, +/// built from the `getEntry` response. +pub fn detail_of(data: msg.EntryDetailData) -> EntryDetail { + EntryDetail( + did: data.did, + handle: data.handle, + entry: data.entry, + events: data.events, + release_info: data.release, + ) +} + +/// Seed an amend draft from the entry's snapshot (title + year as released, +/// rest blank), or a blank draft when the entry isn't loaded (yet). +pub fn seed_amend_draft(model: Model, entry_id: String) -> model.AmendDraft { + case list.find(model.entries(model), fn(e) { e.entry_id == entry_id }) { + Ok(found) -> + model.AmendDraft( + title: found.snapshot.title, + released: found.snapshot.year + |> option.map(int.to_string) + |> option.unwrap(""), + country: "", + genres: "", + styles: "", + refresh_cover: False, + ) + Error(Nil) -> blank_amend() + } +} + +/// After an event lands, refresh the shelf and the open record detail (if any). +pub fn after_write(model: Model) -> Effect(Msg) { + case model.selected { + Some(entry_id) -> + effect.batch([ + load_shelf(None, model.view), + load_entry(None, entry_id, GotEntry), + ]) + None -> load_shelf(None, model.view) + } +} diff --git a/web/src/at_record_web/update/feed.gleam b/web/src/at_record_web/update/feed.gleam new file mode 100644 index 0000000..ba54862 --- /dev/null +++ b/web/src/at_record_web/update/feed.gleam @@ -0,0 +1,119 @@ +import at_record/gen/feed/get_feed_skeleton.{type FeedItem} +import at_record_web/effects.{load_entry, load_feed} +import at_record_web/model.{type Model, FeedFailed, FeedLoaded, Model} +import at_record_web/msg.{type FeedSkeletonData, type Msg, GotFeedEntry} +import at_record_web/update/common.{detail_of, failed} +import gleam/dict +import gleam/list +import gleam/option.{Some} +import lustre/effect.{type Effect} +import rsvp + +pub fn got_feed_skeleton( + model: Model, + result: Result(FeedSkeletonData, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(data) -> #( + Model(..model, feed: FeedLoaded(data.items, data.cursor, data.fallback)), + hydrate_uncached(data.items, model.feed_entries), + ) + Error(_) -> #( + Model( + ..model, + feed: FeedFailed, + notice: failed("Couldn't load the feed."), + ), + effect.none(), + ) + } +} + +pub fn got_feed_entry( + model: Model, + actor: String, + entry_id: String, + result: Result(msg.EntryDetailData, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(data) -> #( + Model( + ..model, + feed_entries: dict.insert( + model.feed_entries, + #(actor, entry_id), + detail_of(data), + ), + ), + effect.none(), + ) + // Per-row degrade: an unhydrated ref just keeps its skeleton placeholder, + // so a failed hydration is silently left uncached rather than crashing. + Error(_) -> #(model, effect.none()) + } +} + +pub fn feed_show_more(model: Model) -> #(Model, Effect(Msg)) { + case model.feed { + FeedLoaded(_, Some(cursor), _) if !model.feed_loading_more -> #( + Model(..model, feed_loading_more: True), + load_feed(Some(cursor)), + ) + _ -> #(model, effect.none()) + } +} + +pub fn got_feed_more( + model: Model, + result: Result(FeedSkeletonData, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(data) -> + case model.feed { + FeedLoaded(items, _, _) -> { + let fresh = model.append_new(items, data.items, model.feed_item_key) + #( + Model( + ..model, + feed: FeedLoaded( + list.append(items, fresh), + data.cursor, + data.fallback, + ), + feed_loading_more: False, + ), + hydrate_uncached(fresh, model.feed_entries), + ) + } + _ -> #(Model(..model, feed_loading_more: False), effect.none()) + } + Error(_) -> #( + Model( + ..model, + feed_loading_more: False, + notice: failed("Couldn't load more of the feed."), + ), + effect.none(), + ) + } +} + +/// Fan out hydration for every entry ref in a batch of feed items not +/// already sitting in the cache, so a re-fetched or overlapping item never +/// re-hydrates a ref that's already loaded. +fn hydrate_uncached( + items: List(FeedItem), + cache: dict.Dict(#(String, String), model.EntryDetail), +) -> Effect(Msg) { + items + |> list.flat_map(fn(item) { item.entries }) + |> list.filter(fn(ref) { !dict.has_key(cache, #(ref.actor, ref.entry_id)) }) + |> list.map(fn(ref) { + load_entry(Some(ref.actor), ref.entry_id, GotFeedEntry( + ref.actor, + ref.entry_id, + _, + )) + }) + |> effect.batch +} diff --git a/web/src/at_record_web/update/inbox.gleam b/web/src/at_record_web/update/inbox.gleam new file mode 100644 index 0000000..f2243b5 --- /dev/null +++ b/web/src/at_record_web/update/inbox.gleam @@ -0,0 +1,186 @@ +import at_record_web/effects.{apply_edit_proposal, load_shelf} +import at_record_web/model.{ + type EditProposal, type Model, InboxLoaded, LoggedOut, Model, Own, OwnCrate, + ProposalApplied, ProposalApplying, ProposalReviewing, ShelfLoaded, +} +import at_record_web/msg.{type AppliedProposal, type Msg} +import at_record_web/route +import at_record_web/update/common.{failed, write_error} +import gleam/list +import gleam/option.{type Option, None} +import gleam/result +import lustre/effect.{type Effect} +import modem +import rsvp + +/// Proposals ignored on a previous visit never make it into the loaded inbox +/// at all (see `ignore_proposal`). +pub fn got_edit_inbox( + model: Model, + result: Result(List(EditProposal), rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(proposals) -> #( + Model( + ..model, + inbox: InboxLoaded( + proposals + |> list.filter(fn(p) { + !model.is_ignored_proposal(model.ignored_proposals, p.uri, p.cid) + }) + |> list.map(fn(p) { + model.InboxCard(proposal: p, status: ProposalReviewing) + }), + ), + busy: False, + ), + effect.none(), + ) + // Only 401 means logged-out; keep auth on transient errors (network, 5xx). + Error(rsvp.HttpError(resp)) -> + case resp.status { + 401 -> #( + Model( + ..model.set_crate(model, OwnCrate, ShelfLoaded(Own, [])), + auth: LoggedOut, + busy: False, + ), + effect.none(), + ) + _ -> #( + Model( + ..model, + inbox: model.InboxFailed, + busy: False, + notice: failed("Couldn't load your edit inbox (server error)."), + ), + effect.none(), + ) + } + Error(_) -> #( + Model( + ..model, + inbox: model.InboxFailed, + busy: False, + notice: failed("Couldn't reach the server."), + ), + effect.none(), + ) + } +} + +pub fn apply_proposal( + model: Model, + uri: String, + cid: String, +) -> #(Model, Effect(Msg)) { + #( + Model(..model, inbox: set_card_status(model.inbox, uri, ProposalApplying)), + apply_edit_proposal(uri, cid), + ) +} + +/// The card swaps for the "Applied: ..." strip; the shelf reloads so the +/// superseding release shows up wherever it's referenced. +pub fn got_apply_proposal( + model: Model, + uri: String, + result: Result(AppliedProposal, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(msg.AppliedProposal(release_uri:)) -> #( + Model( + ..model, + inbox: set_card_status(model.inbox, uri, ProposalApplied(release_uri)), + ), + effect.batch([ + load_shelf(None, model.view), + leave_proposal_detail(model.route, uri), + ]), + ) + Error(e) -> + write_error( + Model( + ..model, + inbox: set_card_status(model.inbox, uri, ProposalReviewing), + ), + e, + "Could not apply that proposal.", + ) + } +} + +/// Dropped from the model right away; the uri+cid pair is also persisted +/// (capped, JSON-encoded) so the proposal stays gone on the next visit - +/// client-only, a repo record would make "ignored" public data. +pub fn ignore_proposal(model: Model, uri: String) -> #(Model, Effect(Msg)) { + let cid = find_proposal_cid(model.inbox, uri) |> option.unwrap("") + let ignored = model.add_ignored_proposal(model.ignored_proposals, uri, cid) + #( + Model( + ..model, + inbox: remove_card(model.inbox, uri), + ignored_proposals: ignored, + ), + effect.batch([ + effects.persist_ignored_proposals(ignored), + leave_proposal_detail(model.route, uri), + ]), + ) +} + +fn find_proposal_cid(inbox: model.Inbox, uri: String) -> Option(String) { + case inbox { + InboxLoaded(cards) -> + cards + |> list.find(fn(c) { c.proposal.uri == uri }) + |> result.map(fn(c) { c.proposal.cid }) + |> option.from_result + _ -> None + } +} + +fn set_card_status( + inbox: model.Inbox, + uri: String, + status: model.ProposalStatus, +) -> model.Inbox { + case inbox { + InboxLoaded(cards) -> + InboxLoaded( + list.map(cards, fn(c) { + case c.proposal.uri == uri { + True -> model.InboxCard(..c, status:) + False -> c + } + }), + ) + other -> other + } +} + +fn remove_card(inbox: model.Inbox, uri: String) -> model.Inbox { + case inbox { + InboxLoaded(cards) -> + InboxLoaded(list.filter(cards, fn(c) { c.proposal.uri != uri })) + other -> other + } +} + +/// After acting on a proposal from its own detail route, return to the +/// inbox list; a no-op when the action fired from the list itself (already +/// on `EditInbox`), so the list page's own apply/ignore isn't disrupted by a +/// redundant navigation and inbox refetch. +fn leave_proposal_detail( + current_route: model.Route, + uri: String, +) -> Effect(Msg) { + case current_route { + model.EditProposalDetail(id) -> + case model.proposal_route_id(uri) == id { + True -> modem.push(route.to_path(model.EditInbox), None, None) + False -> effect.none() + } + _ -> effect.none() + } +} diff --git a/web/src/at_record_web/update/routing.gleam b/web/src/at_record_web/update/routing.gleam new file mode 100644 index 0000000..5a7e2eb --- /dev/null +++ b/web/src/at_record_web/update/routing.gleam @@ -0,0 +1,206 @@ +import at_record_web/effects.{ + discogs_status, load_browse, load_entry, load_feed, load_shelf, +} +import at_record_web/model.{ + type Model, Add, Browse, EditInbox, EditProposalDetail, Feed, FeedLoading, + Model, PressingDetail, PressingLoading, PublicCrate, PublicRecord, Record, + RecordAmend, Scan, ScanDone, crate_of, set_crate_loading_if_absent, +} +import at_record_web/msg.{type Msg, GotEntry} +import at_record_web/update/browse.{overlap_effect} +import at_record_web/update/common.{seed_amend_draft} +import at_record_web/update/scan +import gleam/list +import gleam/option.{None, Some} +import lustre/effect.{type Effect} +import modem + +/// History when there's an in-app entry to land on; otherwise a direct push, +/// so a direct-loaded drill-down's back control never navigates the visitor +/// off the app entirely. +pub fn back(model: Model, fallback: String) -> #(Model, Effect(Msg)) { + case model.nav_depth > 0 { + True -> #(model, modem.back(1)) + False -> #(model, modem.push(fallback, None, None)) + } +} + +/// Every route change lands here, so `nav_depth` (which `back` reads) is +/// bumped in exactly one place regardless of which arm below handles the +/// new route. +pub fn on_route_change( + model: Model, + route: model.Route, +) -> #(Model, Effect(Msg)) { + let model = Model(..model, nav_depth: model.nav_depth + 1) + case route { + // Leaving a record clears the selection so the crate/add views start fresh. + Record(entry_id) -> #( + Model( + ..model, + route: Record(entry_id), + selected: Some(entry_id), + editing: False, + confirm_remove: False, + confirm_logout: False, + entry_detail: model.EntryDetailLoading, + busy: True, + ), + effect.batch([ + scan.leaving_scan(model), + load_entry(None, entry_id, GotEntry), + disarm_confirms(), + ]), + ) + + // Seeds the draft from the entry on screen (title/year), same as the old + // fold-out's ToggleAmend used to; falls back to loading the shelf first + // when it isn't loaded yet (a direct load of this route), re-seeding once + // `GotShelf` lands (see `at_record_web/update/shelf`). + RecordAmend(entry_id) -> { + let updated = + Model( + ..model, + route: RecordAmend(entry_id), + selected: Some(entry_id), + amend: seed_amend_draft(model, entry_id), + editing: False, + confirm_remove: False, + confirm_logout: False, + ) + let shelf_effect = case crate_of(model, model.OwnCrate) { + model.ShelfLoaded(_, _) -> effect.none() + _ -> load_shelf(None, model.view) + } + #( + updated, + effect.batch([scan.leaving_scan(model), disarm_confirms(), shelf_effect]), + ) + } + + // Hydrates off `model.browse` when already loaded there; otherwise falls back to `catalog.getRelease`. + PressingDetail(did, rkey) -> { + let uri = model.release_uri(did, rkey) + let existing = list.find(model.browse, fn(row) { row.uri == uri }) + #( + Model( + ..model, + route: PressingDetail(did, rkey), + selected: None, + confirm_logout: False, + browse_adding: None, + pressing: case existing { + Ok(row) -> model.PressingLoaded(row) + Error(_) -> PressingLoading + }, + ), + effect.batch([ + scan.leaving_scan(model), + disarm_confirms(), + case existing { + Ok(_) -> effect.none() + Error(_) -> effects.load_pressing(uri) + }, + ]), + ) + } + + // Own arm: the done page must keep the run's old/added counts intact. + ScanDone -> { + let updated = + Model( + ..model, + route: ScanDone, + selected: None, + editing: False, + confirm_remove: False, + confirm_logout: False, + browse_adding: None, + ) + #(updated, effect.batch([scan.leaving_scan(model), disarm_confirms()])) + } + + // 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. + route -> { + let scan_state = scan.scan_for_route(model.scan, model.route, route) + let updated = + Model( + ..model, + route:, + selected: None, + editing: False, + confirm_remove: False, + confirm_logout: False, + browse_adding: None, + busy: case route { + Browse | EditInbox | EditProposalDetail(_) -> True + _ -> model.busy + }, + scan: scan_state, + inbox: case route { + EditInbox | EditProposalDetail(_) -> model.InboxLoading + _ -> model.inbox + }, + entry_detail: case route { + PublicRecord(_, _) -> model.EntryDetailLoading + _ -> model.entry_detail + }, + overlap: case route { + PublicCrate(_) -> None + _ -> model.overlap + }, + pressing: PressingLoading, + feed: case route { + Feed -> FeedLoading + _ -> model.feed + }, + ) + // A cached actor crate stays put (stale-while-revalidate): the fetch + // below still fires and refreshes it once it lands, but a fresh + // ShelfLoading only overwrites a key that's never been loaded. + let updated = case route { + PublicCrate(handle) -> + set_crate_loading_if_absent(updated, model.ActorCrate(handle)) + _ -> updated + } + #( + updated, + effect.batch([ + scan.leaving_scan(model), + disarm_confirms(), + case route { + Add -> discogs_status() + Scan -> + effect.batch([ + scan.start_capture(scan_state.mode), + effects.scan_seen(), + ]) + Browse -> load_browse() + Feed -> load_feed(None) + EditInbox | EditProposalDetail(_) -> effects.load_edit_inbox() + PublicCrate(handle) -> + effect.batch([ + load_shelf(Some(handle), "current"), + overlap_effect(model.auth, handle), + ]) + PublicRecord(handle, entry_id) -> + load_entry(Some(handle), entry_id, GotEntry) + _ -> effect.none() + }, + ]), + ) + } + } +} + +/// Both armed confirms reset on every route change; drop their outside-click +/// watchers too so a stale one never lingers on a button that just left the +/// page. +fn disarm_confirms() -> Effect(Msg) { + effect.batch([ + effects.disarm_confirm(model.remove_button_id), + effects.disarm_confirm(model.logout_button_id), + ]) +} diff --git a/web/src/at_record_web/update/scan.gleam b/web/src/at_record_web/update/scan.gleam new file mode 100644 index 0000000..24038e6 --- /dev/null +++ b/web/src/at_record_web/update/scan.gleam @@ -0,0 +1,429 @@ +import at_record_web/effects +import at_record_web/model.{type Model, Model} +import at_record_web/msg.{type Msg, type ScanLookup, BatchItemDone} +import at_record_web/photo_scan +import at_record_web/route +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/set +import lustre/effect.{type Effect} +import modem +import rsvp + +pub fn camera_unsupported( + model: Model, + reason: String, +) -> #(Model, Effect(Msg)) { + #( + Model( + ..model, + scan: model.ScanState(..model.scan, camera_error: Some(reason)), + ), + effect.none(), + ) +} + +pub fn got_scan_seen( + model: Model, + result: Result(set.Set(String), rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(ids) -> #( + Model(..model, scan: model.ScanState(..model.scan, seen_ids: ids)), + effect.none(), + ) + // A stale seen-ids set just means new matches won't show "already owned" until the next fetch. + Error(_) -> #(model, effect.none()) + } +} + +/// The same code repeated back-to-back (still in frame, or a re-shot photo) +/// is dropped outright; a different code always lands, even mid-import. +pub fn barcode_detected( + model: Model, + barcode: String, +) -> #(Model, Effect(Msg)) { + case Some(barcode) == model.scan.last_detected { + True -> #(model, effect.none()) + False -> { + let scan = model.ScanState(..model.scan, last_detected: Some(barcode)) + case list.any(scan.rows, fn(r) { r.barcode == barcode }) { + True -> #(Model(..model, scan:), effect.none()) + False -> #( + Model( + ..model, + scan: model.ScanState(..scan, rows: [ + model.ScanRow(barcode:, outcome: model.Pending), + ..scan.rows + ]), + ), + effects.scan_lookup(barcode), + ) + } + } + } +} + +pub fn got_scan_result( + model: Model, + barcode: String, + result: Result(ScanLookup, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(lookup) -> #( + Model( + ..model, + scan: update_row( + model.scan, + barcode, + scan_outcome(lookup, model.scan.seen_ids), + ), + ), + effect.none(), + ) + Error(_) -> #( + Model(..model, scan: update_row(model.scan, barcode, model.NoMatch([]))), + effect.none(), + ) + } +} + +pub fn set_scan_mode( + model: Model, + mode: model.ScanMode, +) -> #(Model, Effect(Msg)) { + case mode == model.scan.mode { + True -> #(model, effect.none()) + False -> #( + Model( + ..model, + scan: model.ScanState( + ..model.scan, + mode:, + photo_hint: None, + last_detected: None, + ), + ), + effect.batch([stop_capture(model.scan.mode), start_capture(mode)]), + ) + } +} + +pub fn take_photo(model: Model) -> #(Model, Effect(Msg)) { + #( + Model(..model, scan: model.ScanState(..model.scan, photo_hint: None)), + photo_scan.capture_and_decode("scan-video", msg.PhotoCaptured), + ) +} + +/// A decoded shot rejoins the live path: same dedupe, same lookup effect. +pub fn photo_captured( + model: Model, + result: Result(List(String), photo_scan.DecodeError), +) -> #(Model, Effect(Msg)) { + case result { + Ok(codes) -> + case photo_scan.first_barcode(codes) { + Ok(barcode) -> barcode_detected(model, barcode) + Error(Nil) -> #( + Model( + ..model, + scan: model.ScanState( + ..model.scan, + photo_hint: Some("No barcode in that shot. Try again."), + ), + ), + effects.debounce("photo-hint", 4000, msg.ClearPhotoHint), + ) + } + Error(photo_scan.Unsupported(_)) -> #( + Model( + ..model, + scan: model.ScanState( + ..model.scan, + mode: model.LiveMode, + photo_supported: False, + last_detected: None, + ), + ), + effects.start_scanner(), + ) + Error(photo_scan.CaptureFailed(message)) + | Error(photo_scan.DecodeFailed(message)) -> #( + Model(..model, notice: Some(model.Notice(model.Failure, message))), + effect.none(), + ) + } +} + +pub fn clear_photo_hint(model: Model) -> #(Model, Effect(Msg)) { + #( + Model(..model, scan: model.ScanState(..model.scan, photo_hint: None)), + effect.none(), + ) +} + +/// Fires no write: promotes the NoMatch row to a match so it imports with the batch. +pub fn accept_suggestion( + model: Model, + barcode: String, + release: model.DiscogsResult, +) -> #(Model, Effect(Msg)) { + case find_row(model.scan, barcode) { + Some(model.ScanRow(outcome: model.NoMatch(_), ..)) -> #( + Model( + ..model, + scan: update_row( + model.scan, + barcode, + model.Matched(result: release, owned: False), + ), + ), + effect.none(), + ) + _ -> #(model, effect.none()) + } +} + +/// One write in flight at a time; BatchItemDone chains the rest. Double-tap guarded. +pub fn start_batch_import(model: Model) -> #(Model, Effect(Msg)) { + 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()) + } +} + +pub fn batch_item_done( + model: Model, + barcode: String, + result: Result(Nil, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(Nil) -> { + let scan = + model.ScanState( + ..update_row(model.scan, barcode, model.Added), + import_added: model.scan.import_added + 1, + ) + advance_batch(Model(..model, scan:)) + } + // A failed row stays listed (RETRY) instead of aborting the rest of the run. + Error(_) -> { + let scan = case find_row(model.scan, barcode) { + Some(model.ScanRow(outcome: model.Adding(item:), ..)) -> + update_row(model.scan, barcode, model.Failed(item)) + _ -> model.scan + } + advance_batch(Model(..model, scan:)) + } + } +} + +/// Guarded against a race with the running batch loop touching the same row. +pub fn retry_batch_item( + model: Model, + barcode: String, +) -> #(Model, Effect(Msg)) { + 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()) + } +} + +pub fn remove_scan_row(model: Model, barcode: String) -> #(Model, Effect(Msg)) { + #( + Model( + ..model, + scan: model.ScanState( + ..model.scan, + rows: list.filter(model.scan.rows, fn(r) { r.barcode != barcode }), + ), + ), + effect.none(), + ) +} + +fn scan_outcome( + lookup: ScanLookup, + seen_ids: set.Set(String), +) -> model.ScanOutcome { + case lookup.network, lookup.result { + // A network hit wins outright: the server never even queried Discogs. + Some(release), _ -> model.NetworkMatched(release) + None, Some(result) -> + model.Matched( + result:, + owned: set.contains(seen_ids, int.to_string(result.discogs_id)), + ) + None, None -> model.NoMatch(lookup.suggestions) + } +} + +/// An unstarted row's item. `Failed` is excluded so the loop can't re-fire a +/// failure forever; that's RETRY's job. +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` (batch loop and RETRY share this). +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) + } +} + +/// Clear the running flag; any successes refresh the shelf and go to done. +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(model.ScanDone), None, None), + effects.load_shelf(None, model.view), + ]), + ) + False -> #(updated, effect.none()) + } +} + +fn find_row(scan: model.ScanState, barcode: String) -> Option(model.ScanRow) { + list.find(scan.rows, fn(r) { r.barcode == barcode }) |> option.from_result +} + +fn update_row( + scan: model.ScanState, + barcode: String, + outcome: model.ScanOutcome, +) -> model.ScanState { + model.ScanState( + ..scan, + rows: list.map(scan.rows, fn(r) { + case r.barcode == barcode { + True -> model.ScanRow(..r, outcome:) + False -> r + } + }), + ) +} + +/// A fresh entry into the scan/review/done trio starts a blank session; moving +/// within it preserves rows; done -> scan drops imported rows, keeps the rest. +pub fn scan_for_route( + scan: model.ScanState, + previous: model.Route, + route: model.Route, +) -> model.ScanState { + case route { + // Capture (re)starts unconditionally on entry, so the previous session's + // last-seen code must never carry over and jam the fresh detector loop. + model.Scan -> + case previous { + model.ScanReview -> scan + model.ScanDone -> + model.ScanState( + ..scan, + rows: list.filter(scan.rows, fn(r) { r.outcome != model.Added }), + importing: False, + ) + _ -> model.blank_scan() + } + |> fn(s) { model.ScanState(..s, last_detected: None) } + |> arm_capture_modes(previous) + _ -> scan + } +} + +/// Probed on every scan-page entry: a fresh session picks this browser's +/// default mode, while a return from review/done keeps the user's toggle. +fn arm_capture_modes( + scan: model.ScanState, + previous: model.Route, +) -> model.ScanState { + let photo = photo_scan.supports_photo_capture() + let mode = case previous { + model.ScanReview | model.ScanDone -> scan.mode + _ -> model.default_scan_mode(photo_scan.supports_live_detection(), photo) + } + model.ScanState(..scan, mode:, photo_supported: photo, photo_hint: None) +} + +pub fn start_capture(mode: model.ScanMode) -> Effect(Msg) { + case mode { + model.LiveMode -> effects.start_scanner() + model.PhotoMode -> + photo_scan.start_camera("scan-video", msg.CameraUnsupported) + } +} + +pub fn stop_capture(mode: model.ScanMode) -> Effect(Msg) { + case mode { + model.LiveMode -> effects.stop_scanner() + model.PhotoMode -> photo_scan.stop_camera() + } +} + +/// The camera must be released when navigating away from /scan, whichever +/// route comes next. +pub fn leaving_scan(model: Model) -> Effect(Msg) { + case model.route { + model.Scan -> stop_capture(model.scan.mode) + _ -> effect.none() + } +} diff --git a/web/src/at_record_web/update/session.gleam b/web/src/at_record_web/update/session.gleam new file mode 100644 index 0000000..75646e5 --- /dev/null +++ b/web/src/at_record_web/update/session.gleam @@ -0,0 +1,101 @@ +import at_record_web/effects.{handle_search, logout, oauth_login, persist_theme} +import at_record_web/model.{ + type HandleSuggestion, type Model, LoggedOut, Model, OwnCrate, ShelfLoading, +} +import at_record_web/msg.{type Msg, DisarmLogout} +import gleam/option.{None} +import gleam/result +import gleam/string +import lustre/effect.{type Effect} +import rsvp + +/// Typing a handle searches the public directory after a pause, like the +/// title field's Discogs autocomplete. +pub fn handle_changed(model: Model, value: String) -> #(Model, Effect(Msg)) { + let updated = Model(..model, login_handle: value, login_error: None) + case string.length(string.trim(value)) >= 2 { + True -> #(updated, effects.debounce("handle-search", 300, msg.HandleSearch)) + False -> #(Model(..updated, login_suggestions: []), effect.none()) + } +} + +pub fn handle_search_msg(model: Model) -> #(Model, Effect(Msg)) { + #(model, handle_search(string.trim(model.login_handle))) +} + +pub fn got_handle_suggestions( + model: Model, + found: Result(List(HandleSuggestion), rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + #(Model(..model, login_suggestions: result.unwrap(found, [])), effect.none()) +} + +/// Picking a suggestion is a confirmed, unambiguous handle: skip the extra +/// "LOG IN" tap and start OAuth right away. +pub fn use_handle_suggestion( + model: Model, + suggestion: HandleSuggestion, +) -> #(Model, Effect(Msg)) { + #( + Model( + ..model, + login_handle: suggestion.handle, + login_suggestions: [], + login_error: None, + notice: None, + ), + oauth_login(suggestion.handle), + ) +} + +pub fn start_login(model: Model) -> #(Model, Effect(Msg)) { + case string.trim(model.login_handle) { + "" -> #(model, effect.none()) + handle -> #( + Model(..model, notice: None, login_error: None, login_suggestions: []), + oauth_login(handle), + ) + } +} + +pub fn arm_logout(model: Model) -> #(Model, Effect(Msg)) { + #( + Model(..model, confirm_logout: True), + effects.arm_confirm(model.logout_button_id, "confirm-logout", DisarmLogout), + ) +} + +pub fn disarm_logout(model: Model) -> #(Model, Effect(Msg)) { + #( + Model(..model, confirm_logout: False), + effects.disarm_confirm(model.logout_button_id), + ) +} + +pub fn logout_msg(model: Model) -> #(Model, Effect(Msg)) { + #( + Model( + ..model.set_crate(model, OwnCrate, ShelfLoading), + auth: LoggedOut, + notice: None, + avatar: None, + confirm_logout: False, + ), + effect.batch([logout(), effects.disarm_confirm(model.logout_button_id)]), + ) +} + +pub fn got_logout(model: Model) -> #(Model, Effect(Msg)) { + #( + Model( + ..model.set_crate(model, OwnCrate, ShelfLoading), + auth: LoggedOut, + avatar: None, + ), + effect.none(), + ) +} + +pub fn set_theme(model: Model, theme: model.Theme) -> #(Model, Effect(Msg)) { + #(Model(..model, theme:), persist_theme(theme)) +} diff --git a/web/src/at_record_web/update/shelf.gleam b/web/src/at_record_web/update/shelf.gleam new file mode 100644 index 0000000..c8780a5 --- /dev/null +++ b/web/src/at_record_web/update/shelf.gleam @@ -0,0 +1,417 @@ +import at_record_web/effects.{ + copy_record_link, entry_action, load_avatar, load_shelf, load_shelf_more, +} +import at_record_web/model.{ + type Entry, type Model, Actor, ActorCrate, Entry, EntryDetail, + EntryDetailFailed, EntryDetailLoaded, LoggedIn, LoggedOut, Model, Own, + OwnCrate, PublicCrate, PublicRecord, RecordAmend, ShelfFailed, ShelfLoaded, + ShelfLoading, crate_window_size, set_crate, +} +import at_record_web/msg.{type Msg, type ShelfData} +import at_record_web/route +import at_record_web/update/browse.{overlap_effect} +import at_record_web/update/common.{ + after_write, failed, seed_amend_draft, succeeded, +} +import gleam/bool +import gleam/dict +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import lustre/effect.{type Effect} +import rsvp + +pub fn toggle_edit(model: Model) -> #(Model, Effect(Msg)) { + #(Model(..model, editing: bool.negate(model.editing)), effect.none()) +} + +// Switching filter view reloads the shelf and rewinds the window so the +// new view starts from the top rather than deep in the previous one. +// The stale cursor is dropped too: a cursor is scoped to the view it was +// minted under, so it wouldn't resume anywhere useful against a new one. +pub fn set_view(model: Model, view: String) -> #(Model, Effect(Msg)) { + #( + Model( + ..set_crate(model, OwnCrate, ShelfLoading), + view:, + busy: True, + notice: None, + crate_window: crate_window_size, + crate_cursor: None, + shelf_loading_more: False, + ), + load_shelf(None, view), + ) +} + +pub fn set_display( + model: Model, + display: model.Display, +) -> #(Model, Effect(Msg)) { + #(Model(..model, display:), effects.persist_display(display)) +} + +/// Widening the window is free when enough is already fetched; only once +/// the window catches up does this reach for the next page over the wire. +pub fn show_more_crate(model: Model) -> #(Model, Effect(Msg)) { + let new_window = model.crate_window + crate_window_size + case new_window <= list.length(model.entries(model)) { + True -> #(Model(..model, crate_window: new_window), effect.none()) + False -> + case model.crate_cursor { + Some(cursor) -> #( + Model(..model, crate_window: new_window, shelf_loading_more: True), + load_shelf_more(model.view, cursor), + ) + None -> #(Model(..model, crate_window: new_window), effect.none()) + } + } +} + +/// Re-run the shelf load after a failed fetch (or session restore). +pub fn retry_shelf(model: Model) -> #(Model, Effect(Msg)) { + #( + Model( + ..set_crate(model, OwnCrate, ShelfLoading), + busy: True, + notice: None, + crate_window: crate_window_size, + crate_cursor: None, + shelf_loading_more: False, + ), + load_shelf(None, model.view), + ) +} + +pub fn clear_notice(model: Model) -> #(Model, Effect(Msg)) { + #(Model(..model, notice: None), effect.none()) +} + +pub fn got_shelf( + model: Model, + result: Result(ShelfData, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(data) -> { + let updated = + Model( + ..set_crate(model, OwnCrate, ShelfLoaded(Own, data.items)), + auth: LoggedIn(data.handle), + via_handles: data.via_handles, + busy: False, + crate_window: crate_window_size, + crate_cursor: data.cursor, + ) + // A direct load of RecordAmend fires this shelf fetch itself (see + // `at_record_web/update/routing`), since the draft couldn't be seeded + // from entries that weren't loaded yet. + let updated = case model.route { + RecordAmend(entry_id) -> + Model(..updated, amend: seed_amend_draft(updated, entry_id)) + _ -> updated + } + #( + updated, + effect.batch([ + case model.avatar { + None -> load_avatar(data.handle) + Some(_) -> effect.none() + }, + case model.route { + PublicCrate(handle) -> overlap_effect(LoggedIn(data.handle), handle) + _ -> effect.none() + }, + ]), + ) + } + // Only 401 means logged-out; keep auth on transient errors (network, 5xx). + Error(rsvp.HttpError(resp)) -> + case resp.status { + 401 -> #( + Model( + ..set_crate(model, OwnCrate, ShelfLoaded(Own, [])), + auth: LoggedOut, + busy: False, + ), + effect.none(), + ) + _ -> #( + Model( + ..set_crate(model, OwnCrate, ShelfFailed), + busy: False, + notice: failed("Couldn't load your shelf (server error)."), + ), + effect.none(), + ) + } + Error(_) -> #( + Model( + ..set_crate(model, OwnCrate, ShelfFailed), + busy: False, + notice: failed("Couldn't reach the server."), + ), + effect.none(), + ) + } +} + +pub fn got_shelf_more( + model: Model, + result: Result(ShelfData, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(data) -> { + let fresh = + model.append_new(model.entries(model), data.items, fn(e) { + Some(e.entry_id) + }) + let grown = ShelfLoaded(Own, list.append(model.entries(model), fresh)) + #( + Model( + ..set_crate(model, OwnCrate, grown), + via_handles: dict.merge(model.via_handles, data.via_handles), + crate_cursor: data.cursor, + shelf_loading_more: False, + ), + effect.none(), + ) + } + Error(_) -> #( + Model( + ..model, + shelf_loading_more: False, + notice: failed("Couldn't load more of your shelf."), + ), + effect.none(), + ) + } +} + +pub fn arm_remove(model: Model) -> #(Model, Effect(Msg)) { + #( + Model(..model, confirm_remove: True), + effects.arm_confirm( + model.remove_button_id, + "confirm-remove", + msg.DisarmRemove, + ), + ) +} + +pub fn disarm_remove(model: Model) -> #(Model, Effect(Msg)) { + #( + Model(..model, confirm_remove: False), + effects.disarm_confirm(model.remove_button_id), + ) +} + +pub fn entry_action_msg( + model: Model, + entry_id: String, + action: String, +) -> #(Model, Effect(Msg)) { + #( + Model(..model, busy: True, confirm_remove: False), + effect.batch([ + entry_action(entry_id, action), + effects.disarm_confirm(model.remove_button_id), + ]), + ) +} + +// The open record's superset detail (own record page and public record +// page share the `entry_detail` slot; the routes are mutually exclusive). +pub fn got_entry( + model: Model, + result: Result(msg.EntryDetailData, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(data) -> #( + Model( + ..model, + entry_detail: EntryDetailLoaded(common.detail_of(data)), + busy: False, + ), + effect.none(), + ) + Error(_) -> #( + Model(..model, entry_detail: EntryDetailFailed, busy: False), + effect.none(), + ) + } +} + +// Rate/Regrade update the entry in place immediately (the select would +// otherwise sit on the old value, disabled, until a full reload lands) +// and keep the pre-edit entry so a failed write can restore it. +pub fn regrade( + model: Model, + entry_id: String, + grade: String, +) -> #(Model, Effect(Msg)) { + case grade { + "" -> #(model, effect.none()) + grade -> { + let #(updated, backup) = + optimistic_patch(model, entry_id, fn(e) { + Entry(..e, media_grade: Some(grade)) + }) + #( + Model(..updated, busy: True, pending_revert: backup), + effects.regrade(entry_id, grade), + ) + } + } +} + +pub fn rate( + model: Model, + entry_id: String, + rating: String, +) -> #(Model, Effect(Msg)) { + case int.parse(rating) { + Ok(value) -> { + let #(updated, backup) = + optimistic_patch(model, entry_id, fn(e) { + Entry(..e, rating: Some(value)) + }) + #( + Model(..updated, busy: True, pending_revert: backup), + effects.rate(entry_id, value), + ) + } + Error(_) -> #(model, effect.none()) + } +} + +// An optimistic Rate/Regrade is already reflected locally, so its +// success just clears the in-flight marker — no reload needed. Any +// other entry action (sold/dropped/moved, ...) can change which view an +// entry belongs in, so it still needs the full reload. +pub fn got_action( + model: Model, + result: Result(Nil, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(Nil) -> + case model.pending_revert { + Some(_) -> #( + Model(..model, busy: False, pending_revert: None), + effect.none(), + ) + None -> #(Model(..model, busy: True), after_write(model)) + } + Error(e) -> + case model.pending_revert { + Some(original) -> + common.write_error( + Model( + ..model, + entry_detail: set_detail_entry(model.entry_detail, original), + pending_revert: None, + ), + e, + "Could not update the entry.", + ) + None -> common.write_error(model, e, "Could not update the entry.") + } + } +} + +pub fn got_actor_shelf( + model: Model, + result: Result(ShelfData, rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(data) -> #( + set_crate( + model, + ActorCrate(data.handle), + ShelfLoaded(Actor(data.did, data.handle), data.items), + ), + effect.none(), + ) + // The response carries no handle to key off; fall back to whichever + // /u/:handle route is still open (a no-op if the visitor has since + // navigated away). + Error(_) -> + case model.route { + PublicCrate(handle) -> #( + set_crate(model, ActorCrate(handle), ShelfFailed), + effect.none(), + ) + _ -> #(model, effect.none()) + } + } +} + +pub fn got_avatar( + model: Model, + result: Result(Option(String), rsvp.Error(String)), +) -> #(Model, Effect(Msg)) { + case result { + Ok(maybe_url) -> #(Model(..model, avatar: maybe_url), effect.none()) + // Best-effort: the initial-letter fallback stays if the avatar fetch fails. + Error(_) -> #(model, effect.none()) + } +} + +// Only signed-in visitors see the copy-link button (their own record +// page), so a logged-out call is unreachable in practice. +pub fn copy_record_link_msg( + model: Model, + entry_id: String, +) -> #(Model, Effect(Msg)) { + case model.auth { + LoggedIn(handle) -> #( + model, + copy_record_link(route.to_path(PublicRecord(handle, entry_id))), + ) + LoggedOut -> #(model, effect.none()) + } +} + +pub fn link_copied(model: Model, ok: Bool) -> #(Model, Effect(Msg)) { + case ok { + True -> #( + Model(..model, notice: succeeded("Link copied to clipboard.")), + effect.none(), + ) + False -> #( + Model(..model, notice: failed("Couldn't copy the link.")), + effect.none(), + ) + } +} + +/// Applies `patch` to the open record's entry right away (the record page +/// reads it off `entry_detail`), returning the pre-patch entry so the caller +/// can revert on a failed write. A no-op when the record isn't loaded. +fn optimistic_patch( + model: Model, + entry_id: String, + patch: fn(Entry) -> Entry, +) -> #(Model, Option(Entry)) { + case model.entry_detail { + EntryDetailLoaded(d) if d.entry.entry_id == entry_id -> #( + Model( + ..model, + entry_detail: EntryDetailLoaded(EntryDetail(..d, entry: patch(d.entry))), + ), + Some(d.entry), + ) + _ -> #(model, None) + } +} + +/// Restore an entry into `entry_detail` (revert of an optimistic patch). +fn set_detail_entry( + detail: model.EntryDetailState, + entry: Entry, +) -> model.EntryDetailState { + case detail { + EntryDetailLoaded(d) if d.entry.entry_id == entry.entry_id -> + EntryDetailLoaded(EntryDetail(..d, entry:)) + _ -> detail + } +}