From c04bd3f680b710c072ca856d6309e8850a64d6db Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Fri, 17 Jul 2026 10:32:40 +0200 Subject: [PATCH] feat(web): add pressing detail page reached from browse cards and via handles --- web/css/07-browse.css | 23 ++++ web/css/19-pressing.css | 11 ++ web/src/at_record_web.gleam | 5 + web/src/at_record_web/effects.gleam | 21 ++- web/src/at_record_web/model.gleam | 33 +++++ web/src/at_record_web/msg.gleam | 3 + web/src/at_record_web/pages/browse.gleam | 59 ++++++-- web/src/at_record_web/pages/pressing.gleam | 122 +++++++++++++++++ web/src/at_record_web/route.gleam | 6 +- web/src/at_record_web/update.gleam | 93 ++++++++++--- web/src/at_record_web/view.gleam | 7 +- web/test/browse_test.gleam | 2 + web/test/pressing_test.gleam | 148 +++++++++++++++++++++ web/test/support.gleam | 1 + 14 files changed, 504 insertions(+), 30 deletions(-) create mode 100644 web/css/19-pressing.css create mode 100644 web/src/at_record_web/pages/pressing.gleam create mode 100644 web/test/pressing_test.gleam diff --git a/web/css/07-browse.css b/web/css/07-browse.css index 9d16622..6e2ab82 100644 --- a/web/css/07-browse.css +++ b/web/css/07-browse.css @@ -8,6 +8,24 @@ browse card in a row lines up regardless of buttons vs badge. */ .browse-card .cover-card__info { flex: 1; + /* `.browse-card__titles` already carries the top padding, so this stays + flush under it instead of doubling up. */ + padding-top: 0; +} +/* The cover/title area, linking into the pressing detail page; not the + `.cover-card` itself (unlike `cov.cover_card`), so the via-handle link and + the WANT IT/I HAVE THIS actions below can stay outside it. */ +.browse-card__link { + display: flex; + flex-direction: column; + color: inherit; + text-decoration: none; +} +.browse-card__titles { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 10px 0; } /* Handles like @cratedigger.bsky.social have no word breaks, so without this the line wraps mid-word across up to 3 lines instead of reading as content. */ @@ -17,6 +35,11 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-style: italic; +} +.browse-card__via-link { + color: inherit; + text-decoration: underline; } .browse-card .cover-card__info > .badge, .browse-card__actions { diff --git a/web/css/19-pressing.css b/web/css/19-pressing.css new file mode 100644 index 0000000..c3865fd --- /dev/null +++ b/web/css/19-pressing.css @@ -0,0 +1,11 @@ +/* --- pressing detail ---------------------------------------------------- */ +.pressing-via { + font: 400 12px/1.4 var(--mono); + color: var(--ink-muted); + font-style: italic; + margin: 4px 0 0; +} +.pressing-via a { + color: inherit; + text-decoration: underline; +} diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam index d41875e..7e4b977 100644 --- a/web/src/at_record_web.gleam +++ b/web/src/at_record_web.gleam @@ -66,6 +66,7 @@ fn init(_flags) -> #(Model, Effect(Msg)) { ignored_proposals: initial_ignored_proposals(), public_shelf: model.PublicShelfLoading, public_entry: model.PublicEntryLoading, + pressing: model.PressingLoading, ) // modem.init wires up URL routing; clear_query drops a lingering callback ?error=. let routing = modem.init(on_url_change) @@ -80,6 +81,10 @@ fn init(_flags) -> #(Model, Effect(Msg)) { model.PublicRecord(handle, entry_id) -> [ effects.load_public_entry(handle, entry_id), ] + // Nothing's in `model.browse` yet at boot, so a direct load always fetches. + model.PressingDetail(did, rkey) -> [ + effects.load_pressing(model.release_uri(did, rkey)), + ] _ -> [] } let startup = case login_error { diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 1bd1977..ec358d6 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -23,9 +23,9 @@ import at_record_web/msg.{ CoverUploaded, GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, - GotHandleSuggestions, GotLogout, GotPublicEntry, GotPublicShelf, GotScanResult, - GotScanSeen, GotShelf, GotShelfMore, GotTimeline, LinkCopied, PublicEntryData, - PublicShelfData, ScanLookup, ShelfData, TimelineData, + GotHandleSuggestions, GotLogout, GotPressing, GotPublicEntry, GotPublicShelf, + GotScanResult, GotScanSeen, GotShelf, GotShelfMore, GotTimeline, LinkCopied, + PublicEntryData, PublicShelfData, ScanLookup, ShelfData, TimelineData, } import at_record_web/prefs import gleam/dict @@ -647,10 +647,25 @@ fn browse_release_decoder() -> decode.Decoder(BrowseRelease) { thumb_url: row.thumb_url, released: row.released, country: row.country, + adoption_count: row.adoption_count, + variant_count: row.variant_count, ) }) } +/// Direct-load hydration for the pressing detail page: only fired when the +/// uri isn't already sitting in `model.browse` from a browse-page click (see +/// `OnRouteChange(PressingDetail(..))`). +pub fn load_pressing(release_uri: String) -> Effect(Msg) { + rsvp.get( + xrpc("catalog.getRelease") <> "?uri=" <> uri.percent_encode(release_uri), + rsvp.expect_json( + decode.field("release", browse_release_decoder(), decode.success), + GotPressing, + ), + ) +} + /// Want-it / I-have-this quick action from the browse grid: the release is /// already known, so this skips straight to a genesis write. pub fn browse_add(uri: String, cid: String, status: String) -> Effect(Msg) { diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 1b91b86..5d3dadb 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -5,6 +5,7 @@ import gleam/int import gleam/list import gleam/option.{type Option, None} import gleam/set +import gleam/string import gleam/uri pub type Auth { @@ -29,6 +30,8 @@ pub type Route { PublicCrate(handle: String) /// One entry off another user's public crate. PublicRecord(handle: String, entry_id: String) + /// The shared catalog's view of one release, reached off a browse card. + PressingDetail(did: String, rkey: String) } /// How many crate items render before the LOAD MORE button, and the amount @@ -391,9 +394,37 @@ pub type BrowseRelease { thumb_url: Option(String), released: Option(String), country: Option(String), + // Adoption/variant-set stats; both None only if a future row source hydrates without them. + adoption_count: Option(Int), + variant_count: Option(Int), ) } +/// Split a `catalog.release` at:// uri into its did/rkey, the shape the +/// `PressingDetail` route needs. Any uri not shaped like +/// `at:////` yields `Error`. +pub fn split_release_uri(uri: String) -> Result(#(String, String), Nil) { + case string.split(string.replace(uri, "at://", ""), "/") { + [did, _collection, rkey] -> Ok(#(did, rkey)) + _ -> Error(Nil) + } +} + +/// The `catalog.release` collection nsid, so `PressingDetail`'s did/rkey can +/// be rebuilt into the at:// uri `split_release_uri` above undoes. +pub const release_collection = "dev.mokkenstorm.crate.catalog.release" + +pub fn release_uri(did: String, rkey: String) -> String { + "at://" <> did <> "/" <> release_collection <> "/" <> rkey +} + +/// A pressing detail page's load state, mirroring `Shelf`/`PublicShelf`. +pub type PressingState { + PressingLoading + PressingLoaded(BrowseRelease) + PressingFailed +} + /// The diffable fields a `catalog.edit` proposal can carry, both for the /// proposed values and the target release's current ones; a field absent /// means the proposal doesn't touch it (nothing to diff, no row to render). @@ -578,6 +609,8 @@ pub type Model { // Another user's public crate/entry, loaded for the /u/:handle routes. public_shelf: PublicShelf, public_entry: PublicEntryState, + // The pressing detail page's release, for the /pressing/:did/:rkey route. + pressing: PressingState, ) } diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 34dd3ed..7cfbbc8 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -158,4 +158,7 @@ pub type Msg { /// Share the public URL for one of the caller's own entries. CopyRecordLink(entry_id: String) LinkCopied(Bool) + /// `catalog.getRelease`'s response, for a pressing detail page opened + /// directly rather than off a browse card already sitting in `model.browse`. + GotPressing(Result(BrowseRelease, rsvp.Error(String))) } diff --git a/web/src/at_record_web/pages/browse.gleam b/web/src/at_record_web/pages/browse.gleam index 5ade85e..b4277b4 100644 --- a/web/src/at_record_web/pages/browse.gleam +++ b/web/src/at_record_web/pages/browse.gleam @@ -1,9 +1,13 @@ //// The Browse page: a grid of catalog releases from across at-record users. //// Each card offers WANT IT / I HAVE THIS quick actions, or a badge when the -//// viewer already has the release, instead of linking into the add form. +//// viewer already has the release; the cover/title area links into the +//// pressing detail page instead. -import at_record_web/model.{type BrowseRelease, type Model} +import at_record_web/model.{ + type BrowseRelease, type Model, PressingDetail, PublicCrate, +} import at_record_web/msg.{type Msg, BrowseAdd, BrowseGenre, BrowseQuery} +import at_record_web/route import at_record_web/ui/controls as ctl import at_record_web/ui/covers as cov import at_record_web/ui/forms as frm @@ -47,21 +51,60 @@ fn card(row: BrowseRelease, adding: Option(#(String, String))) -> Element(Msg) { let artist = option.unwrap(row.artist_display, "") let color = cov.cover_color(row.title <> artist) html.div([attr.class("cover-card browse-card")], [ + pressing_link(row, color, artist), + html.div([attr.class("cover-card__info")], [ + via_handle(row), + actions(row, adding), + ]), + ]) +} + +/// The cover/title area, linking into the shared catalog's pressing detail +/// page; falls back to a non-link block on the rare uri that doesn't parse +/// (rather than emit a link to nowhere). +fn pressing_link( + row: BrowseRelease, + color: String, + artist: String, +) -> Element(Msg) { + let content = [ html.div([attr.class("cover-tile cover-tile--card cover-tile--" <> color)], [ cov.tile_art(option.or(row.cover_url, row.thumb_url), row.title), ]), - html.div([attr.class("cover-card__info")], [ + html.div([attr.class("browse-card__titles")], [ html.span([attr.class("cover-card__title")], [text(row.title)]), html.span([attr.class("cover-card__artist")], [text(artist)]), - html.span([attr.class("item-meta browse-card__via")], [ - text("via @" <> row.publisher_handle), - ]), - actions(row, adding), ]), + ] + case model.split_release_uri(row.uri) { + Ok(#(did, rkey)) -> + html.a( + [ + attr.class("browse-card__link"), + attr.href(route.to_path(PressingDetail(did, rkey))), + ], + content, + ) + Error(_) -> html.div([attr.class("browse-card__link")], content) + } +} + +/// "via @handle", the handle itself linking to that user's public crate; +/// kept out of `pressing_link`'s anchor so the two links don't nest. +fn via_handle(row: BrowseRelease) -> Element(Msg) { + html.span([attr.class("item-meta browse-card__via")], [ + text("via "), + html.a( + [ + attr.class("browse-card__via-link"), + attr.href(route.to_path(PublicCrate(row.publisher_handle))), + ], + [text("@" <> row.publisher_handle)], + ), ]) } -fn actions( +pub fn actions( row: BrowseRelease, adding: Option(#(String, String)), ) -> Element(Msg) { diff --git a/web/src/at_record_web/pages/pressing.gleam b/web/src/at_record_web/pages/pressing.gleam new file mode 100644 index 0000000..4821a71 --- /dev/null +++ b/web/src/at_record_web/pages/pressing.gleam @@ -0,0 +1,122 @@ +//// The Pressing detail page: the shared catalog's view of one canonical +//// release. Hydrated straight off `model.browse` when the browse card that +//// linked here is already loaded; otherwise fetched fresh via +//// `catalog.getRelease` (see `effects.load_pressing`). + +import at_record_web/model.{ + type BrowseRelease, type Model, PressingFailed, PressingLoaded, + PressingLoading, PublicCrate, +} +import at_record_web/msg.{type Msg} +import at_record_web/pages/browse +import at_record_web/route +import at_record_web/ui/controls as ctl +import at_record_web/ui/covers as cov +import at_record_web/ui/forms as frm +import at_record_web/ui/record_detail as rd +import gleam/int +import gleam/list +import gleam/option +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html + +pub fn view(model: Model) -> Element(Msg) { + case model.pressing { + PressingLoading -> loading_state() + PressingFailed -> failed_state() + PressingLoaded(row) -> loaded_state(model, row) + } +} + +fn loaded_state(model: Model, row: BrowseRelease) -> Element(Msg) { + html.div([attr.class("page-scroll")], [ + hero(row), + html.div([attr.class("detail-body")], [ + frm.section_label("THE PRESSING · SHARED CATALOG"), + detail_rows(row), + via_line(row), + stat_chips(row), + browse.actions(row, model.browse_adding), + ]), + ]) +} + +fn hero(row: BrowseRelease) -> Element(Msg) { + let artist = option.unwrap(row.artist_display, "") + let color = cov.cover_color(row.title <> artist) + html.div([], [ + html.div( + [attr.class("cover-tile cover-tile--detail cover-tile--" <> color)], + [cov.tile_art(option.or(row.cover_url, row.thumb_url), row.title)], + ), + html.div([attr.class("titleblock")], [ + html.h1([attr.class("titleblock__title")], [text(row.title)]), + html.span([attr.class("titleblock__artist")], [text(artist)]), + ]), + ]) +} + +/// FORMAT/LABEL aren't in the shared catalog's browse row shape today, so +/// only what's actually hydrated (YEAR, COUNTRY) ever renders here. +fn detail_rows(row: BrowseRelease) -> Element(Msg) { + let rows = + [ + option.map(row.released, fn(y) { #("YEAR", y) }), + option.map(row.country, fn(c) { #("COUNTRY", c) }), + ] + |> option.values + case rows { + [] -> element.none() + _ -> + html.div( + [attr.class("detail-rows")], + list.map(rows, fn(r) { rd.detail_row(r.0, r.1) }), + ) + } +} + +fn via_line(row: BrowseRelease) -> Element(Msg) { + html.p([attr.class("pressing-via")], [ + text("minted via "), + html.a([attr.href(route.to_path(PublicCrate(row.publisher_handle)))], [ + text("@" <> row.publisher_handle), + ]), + ]) +} + +fn stat_chips(row: BrowseRelease) -> Element(Msg) { + let chips = + [ + option.map(row.adoption_count, fn(n) { + ctl.chip("IN " <> int.to_string(n) <> " CRATES", ctl.Surface) + }), + option.map(row.variant_count, fn(n) { + ctl.chip(int.to_string(n) <> " VERSIONS", ctl.Surface) + }), + ] + |> option.values + case chips { + [] -> element.none() + _ -> html.div([attr.class("tags")], chips) + } +} + +fn loading_state() -> Element(Msg) { + html.div([attr.class("page-scroll")], [ + html.p([attr.class("loading-status")], [text("◌ FETCHING…")]), + ]) +} + +fn failed_state() -> Element(Msg) { + html.div([attr.class("page-scroll")], [ + html.div([attr.class("error-state")], [ + html.span([attr.class("error-state__sticker")], [ + text("✕ COULDN'T LOAD"), + ]), + html.p([attr.class("error-state__body")], [ + text("Couldn't load that pressing right now."), + ]), + ]), + ]) +} diff --git a/web/src/at_record_web/route.gleam b/web/src/at_record_web/route.gleam index d7f88db..222ba7f 100644 --- a/web/src/at_record_web/route.gleam +++ b/web/src/at_record_web/route.gleam @@ -1,8 +1,8 @@ //// URL <-> Route mapping for modem. import at_record_web/model.{ - type Route, Add, Browse, Crate, EditInbox, EditProposalDetail, PublicCrate, - PublicRecord, Record, Scan, ScanDone, ScanReview, Settings, + type Route, Add, Browse, Crate, EditInbox, EditProposalDetail, PressingDetail, + PublicCrate, PublicRecord, Record, Scan, ScanDone, ScanReview, Settings, } import gleam/uri.{type Uri} @@ -19,6 +19,7 @@ pub fn parse(target: Uri) -> Route { ["record", entry_id] -> Record(entry_id) ["u", handle] -> PublicCrate(handle) ["u", handle, "record", entry_id] -> PublicRecord(handle, entry_id) + ["pressing", did, rkey] -> PressingDetail(did, rkey) _ -> Crate } } @@ -37,5 +38,6 @@ pub fn to_path(route: Route) -> String { Record(entry_id) -> "/record/" <> entry_id PublicCrate(handle) -> "/u/" <> handle PublicRecord(handle, entry_id) -> "/u/" <> handle <> "/record/" <> entry_id + PressingDetail(did, rkey) -> "/pressing/" <> did <> "/" <> rkey } } diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index fbc0655..fdf7f61 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -2,17 +2,20 @@ 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, handle_search, load_avatar, load_browse, - load_public_entry, load_public_shelf, load_shelf, load_shelf_more, - load_timeline, logout, oauth_login, rate, regrade, search_browse, + load_pressing, load_public_entry, load_public_shelf, load_shelf, + load_shelf_more, load_timeline, logout, oauth_login, rate, regrade, + search_browse, } import at_record_web/model.{ - type BrowseRelease, type Entry, type Model, type Notice, Add, Browse, Discogs, - EditInbox, EditProposalDetail, Entry, Failure, Form, InboxLoaded, InboxLoading, - LoggedIn, LoggedOut, Model, Notice, ProposalApplied, ProposalApplying, - ProposalReviewing, PublicCrate, PublicEntryFailed, PublicEntryLoaded, - PublicEntryLoading, PublicRecord, PublicShelfFailed, PublicShelfLoaded, - PublicShelfLoading, Record, Scan, ScanDone, ScanReview, ShelfFailed, - ShelfLoaded, ShelfLoading, Success, Warning, blank_form, crate_window_size, + type BrowseRelease, type Entry, type Model, type Notice, Add, Browse, + BrowseRelease, Discogs, EditInbox, EditProposalDetail, Entry, Failure, Form, + InboxLoaded, InboxLoading, LoggedIn, LoggedOut, Model, Notice, PressingDetail, + PressingFailed, PressingLoaded, PressingLoading, ProposalApplied, + ProposalApplying, ProposalReviewing, PublicCrate, PublicEntryFailed, + PublicEntryLoaded, PublicEntryLoading, PublicRecord, PublicShelfFailed, + PublicShelfLoaded, PublicShelfLoading, Record, Scan, ScanDone, ScanReview, + ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, blank_form, + crate_window_size, } import at_record_web/money import at_record_web/msg.{ @@ -27,12 +30,12 @@ import at_record_web/msg.{ FormTitle, FormYear, GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, - GotHandleSuggestions, GotLogout, GotPublicEntry, GotPublicShelf, GotScanResult, - GotScanSeen, GotShelf, GotShelfMore, GotTimeline, HandleChanged, HandleSearch, - IgnoreProposal, LinkCopied, Logout, OnRouteChange, PhotoCaptured, Rate, - Regrade, RemoveScanRow, RetryBatchItem, RetryShelf, SetDisplay, SetScanMode, - SetView, ShowMoreCrate, StartBatchImport, StartLogin, SubmitAdd, SubmitAmend, - TakePhoto, ToggleAmend, ToggleAmendCover, ToggleEdit, ToggleEntry, + GotHandleSuggestions, GotLogout, GotPressing, GotPublicEntry, GotPublicShelf, + GotScanResult, GotScanSeen, GotShelf, GotShelfMore, GotTimeline, HandleChanged, + HandleSearch, IgnoreProposal, LinkCopied, Logout, OnRouteChange, PhotoCaptured, + Rate, Regrade, RemoveScanRow, RetryBatchItem, RetryShelf, SetDisplay, + SetScanMode, SetView, ShowMoreCrate, StartBatchImport, StartLogin, SubmitAdd, + SubmitAmend, TakePhoto, ToggleAmend, ToggleAmendCover, ToggleEdit, ToggleEntry, TriggerBrowseSearch, UseArtist, UseDiscogs, UseHandleSuggestion, } import at_record_web/photo_scan @@ -70,6 +73,33 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { disarm_confirms(), ]), ) + // Hydrates off `model.browse` when already loaded there; otherwise falls back to `catalog.getRelease`. + OnRouteChange(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. OnRouteChange(ScanDone) -> { let updated = @@ -120,6 +150,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { PublicRecord(_, _) -> PublicEntryLoading _ -> model.public_entry }, + pressing: PressingLoading, ), effect.batch([ leaving_scan(model), @@ -1013,6 +1044,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 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(), @@ -1024,6 +1056,15 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { "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( @@ -1511,6 +1552,28 @@ fn mark_browse_row( } } +/// 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), diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index 0194bdf..83f1dc6 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -4,8 +4,8 @@ import at_record_web/model.{ type Entry, type Model, type Notice, type NoticeLevel, Add, Browse, Crate, EditInbox, EditProposalDetail, Failure, Info, LoggedIn, LoggedOut, Notice, - PublicCrate, PublicRecord, Record, Scan, ScanDone, ScanReview, Settings, - Success, Warning, + PressingDetail, PublicCrate, PublicRecord, Record, Scan, ScanDone, ScanReview, + Settings, Success, Warning, } import at_record_web/msg.{type Msg, ClearNotice} import at_record_web/pages/add @@ -14,6 +14,7 @@ import at_record_web/pages/crate import at_record_web/pages/edit_inbox import at_record_web/pages/edit_proposal import at_record_web/pages/login +import at_record_web/pages/pressing import at_record_web/pages/public_crate import at_record_web/pages/public_record import at_record_web/pages/record @@ -100,6 +101,7 @@ fn page(model: Model) -> Element(Msg) { Some(entry) -> record.view(model, entry) None -> crate.view(model) } + PressingDetail(_, _) -> pressing.view(model) // Dispatched by `public_page` before `authed_view`/`page` are reached. PublicCrate(_) | PublicRecord(_, _) -> crate.view(model) } @@ -131,6 +133,7 @@ fn app_bar(model: Model) -> Element(Msg) { back_bar_to("SUGGESTED FIX", route.to_path(EditInbox)) Settings -> back_bar("SETTINGS") Record(_) -> back_bar("RECORD") + PressingDetail(_, _) -> back_bar("PRESSING") // Dispatched by `public_app_bar` before `authed_view`/`app_bar` are reached. PublicCrate(_) | PublicRecord(_, _) -> back_bar("RECORD") } diff --git a/web/test/browse_test.gleam b/web/test/browse_test.gleam index 4926dc5..3c1154c 100644 --- a/web/test/browse_test.gleam +++ b/web/test/browse_test.gleam @@ -21,6 +21,8 @@ fn a_browse_release(uri: String) -> BrowseRelease { thumb_url: None, released: None, country: None, + adoption_count: None, + variant_count: None, ) } diff --git a/web/test/pressing_test.gleam b/web/test/pressing_test.gleam new file mode 100644 index 0000000..e76aaf6 --- /dev/null +++ b/web/test/pressing_test.gleam @@ -0,0 +1,148 @@ +//// The pressing detail route (`/pressing/:did/:rkey`): parsing, hydration +//// off an already-loaded browse row vs. a fresh `catalog.getRelease` fetch, +//// and the shared adopt flow's owned/wanted flip. + +import at_record_web/model.{ + type BrowseRelease, BrowseRelease, Model, PressingDetail, PressingFailed, + PressingLoaded, PressingLoading, +} +import at_record_web/msg.{GotBrowseAdd, GotPressing, OnRouteChange} +import at_record_web/route +import at_record_web/update.{update} +import at_record_web/view +import gleam/option.{None, Some} +import gleam/string +import gleam/uri +import lustre/element +import rsvp +import support.{base, empty_effect, logged_in} + +fn a_pressing(uri: String) -> BrowseRelease { + BrowseRelease( + uri:, + cid: "cid-1", + title: "Spiderland", + genres: [], + styles: [], + publisher_did: "did:plc:abc", + publisher_handle: "alice.test", + owned: False, + wanted: False, + artist_display: Some("Slint"), + cover_url: None, + thumb_url: None, + released: Some("1991"), + country: Some("US"), + adoption_count: Some(3), + variant_count: Some(2), + ) +} + +pub fn pressing_route_round_trips_test() { + let parsed = PressingDetail("did:plc:abc", "3jz") + assert route.to_path(parsed) == "/pressing/did:plc:abc/3jz" + let assert Ok(target) = uri.parse(route.to_path(parsed)) + assert route.parse(target) == parsed +} + +pub fn split_release_uri_round_trips_test() { + let uri = model.release_uri("did:plc:abc", "3jz") + assert model.split_release_uri(uri) == Ok(#("did:plc:abc", "3jz")) +} + +pub fn split_release_uri_rejects_a_malformed_uri_test() { + assert model.split_release_uri("not-a-uri") == Error(Nil) +} + +pub fn on_route_change_hydrates_from_an_already_loaded_browse_row_test() { + let uri = model.release_uri("did:plc:abc", "3jz") + let seeded = Model(..logged_in(), browse: [a_pressing(uri)]) + let #(model, _) = + update(seeded, OnRouteChange(PressingDetail("did:plc:abc", "3jz"))) + assert model.route == PressingDetail("did:plc:abc", "3jz") + assert model.pressing == PressingLoaded(a_pressing(uri)) +} + +pub fn on_route_change_fetches_when_not_already_loaded_test() { + let #(model, effect) = + update(logged_in(), OnRouteChange(PressingDetail("did:plc:abc", "3jz"))) + assert model.pressing == PressingLoading + assert effect != empty_effect() +} + +pub fn got_pressing_ok_loads_the_release_test() { + let uri = model.release_uri("did:plc:abc", "3jz") + let #(model, _) = update(base(), GotPressing(Ok(a_pressing(uri)))) + assert model.pressing == PressingLoaded(a_pressing(uri)) +} + +pub fn got_pressing_error_marks_failed_test() { + let #(model, _) = update(base(), GotPressing(Error(rsvp.NetworkError))) + assert model.pressing == PressingFailed +} + +pub fn browse_add_success_flips_the_pressing_row_too_test() { + let uri = model.release_uri("did:plc:abc", "3jz") + let seeded = + Model( + ..logged_in(), + pressing: PressingLoaded(a_pressing(uri)), + browse_adding: Some(#(uri, "owned")), + ) + let #(model, _) = update(seeded, GotBrowseAdd(uri, Ok(Nil))) + assert model.pressing + == PressingLoaded(BrowseRelease(..a_pressing(uri), owned: True)) +} + +pub fn pressing_page_renders_the_loaded_release_test() { + let uri = model.release_uri("did:plc:abc", "3jz") + let html = + Model( + ..logged_in(), + route: PressingDetail("did:plc:abc", "3jz"), + pressing: PressingLoaded(a_pressing(uri)), + ) + |> view.view + |> element.to_string + assert string.contains(html, "Spiderland") + assert string.contains(html, "Slint") + assert string.contains(html, "minted via") + assert string.contains(html, "@alice.test") + assert string.contains(html, "IN 3 CRATES") + assert string.contains(html, "2 VERSIONS") +} + +pub fn pressing_page_renders_owned_state_test() { + let uri = model.release_uri("did:plc:abc", "3jz") + let html = + Model( + ..logged_in(), + route: PressingDetail("did:plc:abc", "3jz"), + pressing: PressingLoaded(BrowseRelease(..a_pressing(uri), owned: True)), + ) + |> view.view + |> element.to_string + assert string.contains(html, "IN YOUR CRATE") +} + +pub fn pressing_page_renders_failed_state_test() { + let html = + Model( + ..logged_in(), + route: PressingDetail("did:plc:abc", "3jz"), + pressing: PressingFailed, + ) + |> view.view + |> element.to_string + assert string.contains(html, "COULDN") +} + +pub fn browse_card_links_to_the_pressing_page_test() { + let uri = model.release_uri("did:plc:abc", "3jz") + let html = + Model(..logged_in(), route: model.Browse, browse: [a_pressing(uri)]) + |> view.view + |> element.to_string + assert string.contains(html, "/pressing/did:plc:abc/3jz") + assert string.contains(html, "/u/alice.test") +} diff --git a/web/test/support.gleam b/web/test/support.gleam index c0cd967..e0f617a 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -61,6 +61,7 @@ pub fn base() -> Model { ignored_proposals: [], public_shelf: model.PublicShelfLoading, public_entry: model.PublicEntryLoading, + pressing: model.PressingLoading, ) } -- 2.51.2