From 03db70f18b45c2af4e9a205ecf7b62e89a971aff Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Thu, 16 Jul 2026 21:54:18 +0200 Subject: [PATCH] feat(web): add public /u/:handle crate and record routes --- web/src/at_record_web.gleam | 6 + web/src/at_record_web/browser.gleam | 5 + web/src/at_record_web/effects.gleam | 51 ++++++- web/src/at_record_web/ffi.mjs | 9 ++ web/src/at_record_web/model.gleam | 27 ++++ web/src/at_record_web/msg.gleam | 21 +++ .../at_record_web/pages/public_crate.gleam | 114 ++++++++++++++ .../at_record_web/pages/public_record.gleam | 86 +++++++++++ web/src/at_record_web/pages/record.gleam | 38 ++++- web/src/at_record_web/route.gleam | 8 +- web/src/at_record_web/update.gleam | 92 +++++++++-- web/src/at_record_web/view.gleam | 45 +++++- web/test/public_crate_test.gleam | 143 ++++++++++++++++++ web/test/record_test.gleam | 6 + web/test/support.gleam | 2 + 15 files changed, 619 insertions(+), 34 deletions(-) create mode 100644 web/src/at_record_web/pages/public_crate.gleam create mode 100644 web/src/at_record_web/pages/public_record.gleam create mode 100644 web/test/public_crate_test.gleam diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam index 119eb02..d41875e 100644 --- a/web/src/at_record_web.gleam +++ b/web/src/at_record_web.gleam @@ -64,6 +64,8 @@ fn init(_flags) -> #(Model, Effect(Msg)) { inbox: model.InboxLoading, confirm_logout: False, ignored_proposals: initial_ignored_proposals(), + public_shelf: model.PublicShelfLoading, + public_entry: model.PublicEntryLoading, ) // modem.init wires up URL routing; clear_query drops a lingering callback ?error=. let routing = modem.init(on_url_change) @@ -74,6 +76,10 @@ fn init(_flags) -> #(Model, Effect(Msg)) { model.Add -> [effects.discogs_status()] model.Browse -> [effects.load_browse()] model.EditInbox -> [effects.load_edit_inbox()] + model.PublicCrate(handle) -> [effects.load_public_shelf(handle)] + model.PublicRecord(handle, entry_id) -> [ + effects.load_public_entry(handle, entry_id), + ] _ -> [] } let startup = case login_error { diff --git a/web/src/at_record_web/browser.gleam b/web/src/at_record_web/browser.gleam index c064bd5..9e9e179 100644 --- a/web/src/at_record_web/browser.gleam +++ b/web/src/at_record_web/browser.gleam @@ -29,3 +29,8 @@ pub fn start_scanner( @external(javascript, "./ffi.mjs", "stopScanner") pub fn stop_scanner() -> Nil + +/// Copy an absolute URL built from `path` (joined against the current +/// origin) to the clipboard; `callback` carries whether the write worked. +@external(javascript, "./ffi.mjs", "copyToClipboard") +pub fn copy_to_clipboard(path: String, callback: fn(Bool) -> Nil) -> Nil diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index bd00f45..245b0b1 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -23,8 +23,9 @@ import at_record_web/msg.{ CoverUploaded, GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, - GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, GotShelf, - GotShelfMore, GotTimeline, ScanLookup, ShelfData, TimelineData, + GotHandleSuggestions, GotLogout, GotPublicEntry, GotPublicShelf, + GotScanResult, GotScanSeen, GotShelf, GotShelfMore, GotTimeline, LinkCopied, + PublicEntryData, PublicShelfData, ScanLookup, ShelfData, TimelineData, } import at_record_web/prefs import gleam/dict @@ -802,6 +803,52 @@ fn release_info_decoder() -> decode.Decoder(ReleaseInfo) { }) } +/// Another user's folded public crate, no auth: same row shape as +/// `load_shelf`'s items, so it shares `entry_decoder`. +pub fn load_public_shelf(handle: String) -> Effect(Msg) { + let decoder = { + use did <- decode.field("did", decode.string) + use handle <- decode.field("handle", decode.string) + use items <- decode.field("items", decode.list(entry_decoder())) + decode.success(PublicShelfData(did:, handle:, items:)) + } + let url = + xrpc("shelf.getPublicShelf") <> "?actor=" <> uri.percent_encode(handle) + rsvp.get(url, rsvp.expect_json(decoder, GotPublicShelf)) +} + +/// One entry off another user's public crate, plus its resolved release +/// fields; shares its decoders with `load_public_shelf`/`load_timeline`. +pub fn load_public_entry(handle: String, entry_id: String) -> Effect(Msg) { + let decoder = { + use did <- decode.field("did", decode.string) + use handle <- decode.field("handle", decode.string) + use entry <- decode.field("entry", entry_decoder()) + use release <- decode.optional_field( + "release", + None, + decode.optional(release_info_decoder()), + ) + decode.success(PublicEntryData(did:, handle:, entry:, release:)) + } + let url = + xrpc("shelf.getPublicEntry") + <> "?actor=" + <> uri.percent_encode(handle) + <> "&entryId=" + <> uri.percent_encode(entry_id) + rsvp.get(url, rsvp.expect_json(decoder, GotPublicEntry)) +} + +/// Copy a shareable link (an absolute URL, built client-side from `path`) to +/// the clipboard; `LinkCopied` carries whether it actually worked, so the UI +/// can show a notice either way. +pub fn copy_record_link(path: String) -> Effect(Msg) { + effect.from(fn(dispatch) { + browser.copy_to_clipboard(path, fn(ok) { dispatch(LinkCopied(ok)) }) + }) +} + fn nil_decoder() -> decode.Decoder(Nil) { decode.success(Nil) } diff --git a/web/src/at_record_web/ffi.mjs b/web/src/at_record_web/ffi.mjs index 113e4e1..732892d 100644 --- a/web/src/at_record_web/ffi.mjs +++ b/web/src/at_record_web/ffi.mjs @@ -159,3 +159,12 @@ export function prefsSet(key, value) { // best-effort } } + +export function copyToClipboard(path, cb) { + const url = (globalThis.location && globalThis.location.origin || "") + path; + if (!navigator.clipboard || !navigator.clipboard.writeText) { + cb(false); + return; + } + navigator.clipboard.writeText(url).then(() => cb(true)).catch(() => cb(false)); +} diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index c03fd64..171a835 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -22,6 +22,10 @@ pub type Route { Browse EditInbox Settings + /// Another user's public crate, read-only and requiring no auth. + PublicCrate(handle: String) + /// One entry off another user's public crate. + PublicRecord(handle: String, entry_id: String) } /// How many crate items render before the LOAD MORE button, and the amount @@ -111,6 +115,26 @@ pub fn entries(model: Model) -> List(Entry) { } } +/// Another user's public crate load state, mirroring `Shelf`. +pub type PublicShelf { + PublicShelfLoading + PublicShelfLoaded(did: String, handle: String, items: List(Entry)) + PublicShelfFailed +} + +/// One entry off another user's public crate, plus its resolved release +/// fields; mirrors `PublicShelf`'s load-state shape. +pub type PublicEntryState { + PublicEntryLoading + PublicEntryLoaded( + did: String, + handle: String, + entry: Entry, + release_info: Option(ReleaseInfo), + ) + PublicEntryFailed +} + /// How prominent/urgent a notice is; drives its icon and colour. pub type NoticeLevel { Success @@ -530,6 +554,9 @@ pub type Model { // Locally-ignored edit-proposal uri+cid pairs, loaded from // `prefs.ignored_proposals_key` at init; see `add_ignored_proposal`. ignored_proposals: List(#(String, String)), + // Another user's public crate/entry, loaded for the /u/:handle routes. + public_shelf: PublicShelf, + public_entry: PublicEntryState, ) } diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 565dfc4..34dd3ed 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -32,6 +32,22 @@ pub type TimelineData { TimelineData(events: List(ShelfEntry), release: Option(ReleaseInfo)) } +/// `getPublicShelf`'s response: another user's folded crate, no auth. +pub type PublicShelfData { + PublicShelfData(did: String, handle: String, items: List(Entry)) +} + +/// `getPublicEntry`'s response: one entry off another user's crate plus its +/// resolved release fields. +pub type PublicEntryData { + PublicEntryData( + did: String, + handle: String, + entry: Entry, + release: Option(ReleaseInfo), + ) +} + /// A barcode lookup's result: a network match wins outright when present /// (Discogs was never even queried); otherwise the best Discogs match if /// any, plus "did you mean" suggestions when the barcode itself resolved to @@ -137,4 +153,9 @@ pub type Msg { result: Result(AppliedProposal, rsvp.Error(String)), ) IgnoreProposal(uri: String) + GotPublicShelf(Result(PublicShelfData, rsvp.Error(String))) + GotPublicEntry(Result(PublicEntryData, rsvp.Error(String))) + /// Share the public URL for one of the caller's own entries. + CopyRecordLink(entry_id: String) + LinkCopied(Bool) } diff --git a/web/src/at_record_web/pages/public_crate.gleam b/web/src/at_record_web/pages/public_crate.gleam new file mode 100644 index 0000000..8162c03 --- /dev/null +++ b/web/src/at_record_web/pages/public_crate.gleam @@ -0,0 +1,114 @@ +//// Another user's public crate: a read-only grid, no filters and no +//// per-entry actions. Reuses the crate grid's cover components; each card +//// links into the public record route instead of the authed one. + +import at_record/gen/defs.{type Snapshot} +import at_record_web/model.{ + type Entry, type Model, PublicRecord, PublicShelfFailed, PublicShelfLoaded, + PublicShelfLoading, +} +import at_record_web/msg.{type Msg} +import at_record_web/route +import at_record_web/ui/covers as cov +import gleam/int +import gleam/list +import gleam/option +import gleam/string +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html + +pub fn view(model: Model, handle: String) -> Element(Msg) { + case model.public_shelf { + PublicShelfLoading -> loading_state(handle) + PublicShelfFailed -> failed_state(handle) + PublicShelfLoaded(_, loaded_handle, []) if loaded_handle == handle -> + empty_state(handle) + PublicShelfLoaded(_, loaded_handle, items) if loaded_handle == handle -> + loaded_state(handle, items) + PublicShelfLoaded(_, _, _) -> loading_state(handle) + } +} + +fn loaded_state(handle: String, items: List(Entry)) -> Element(Msg) { + html.div([attr.class("list-screen")], [ + hero(handle, list.length(items)), + html.div([attr.class("body list-body")], [ + html.div([attr.class("list-scroll")], [grid(handle, items)]), + ]), + ]) +} + +fn hero(handle: String, count: Int) -> Element(Msg) { + html.div([attr.class("hero")], [ + html.span([attr.class("hero__sticker")], [text("@" <> handle)]), + html.span([attr.class("hero__count")], [ + text(int.to_string(count) <> " in the crate"), + ]), + ]) +} + +fn grid(handle: String, items: List(Entry)) -> Element(Msg) { + html.div([attr.class("grid")], list.map(items, card(handle, _))) +} + +fn card(handle: String, entry: Entry) -> Element(Msg) { + let snap = entry.snapshot + cov.cover_card( + route.to_path(PublicRecord(handle, entry.entry_id)), + snap.title <> snap.artist_display, + entry.status, + snap.title, + snap.artist_display, + format_line(snap), + snap.thumb_url, + ) +} + +fn format_line(snap: Snapshot) -> String { + [snap.format, option.map(snap.year, int.to_string)] + |> option.values + |> string.join(" / ") +} + +fn loading_state(handle: String) -> Element(Msg) { + html.div([attr.class("list-screen")], [ + hero(handle, 0), + html.div([attr.class("body list-body")], [ + html.div([attr.class("list-scroll")], [ + html.p([attr.class("loading-status")], [text("◌ FETCHING…")]), + ]), + ]), + ]) +} + +fn empty_state(handle: String) -> Element(Msg) { + html.div([attr.class("list-screen")], [ + hero(handle, 0), + html.div([attr.class("body list-body")], [ + html.div([attr.class("list-scroll")], [ + html.p([attr.class("empty")], [ + text("Nothing public in @" <> handle <> "'s crate yet."), + ]), + ]), + ]), + ]) +} + +fn failed_state(handle: String) -> Element(Msg) { + html.div([attr.class("list-screen")], [ + hero(handle, 0), + html.div([attr.class("body list-body")], [ + html.div([attr.class("list-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 @" <> handle <> "'s crate right now."), + ]), + ]), + ]), + ]), + ]) +} diff --git a/web/src/at_record_web/pages/public_record.gleam b/web/src/at_record_web/pages/public_record.gleam new file mode 100644 index 0000000..03a95d4 --- /dev/null +++ b/web/src/at_record_web/pages/public_record.gleam @@ -0,0 +1,86 @@ +//// One entry off another user's public crate: a read-only detail view, no +//// edit/amend/remove actions and no event timeline (the folded entry plus +//// its resolved release fields is all `getPublicEntry` returns). Reuses the +//// pure display helpers off the authed record page. + +import at_record_web/model.{ + type Entry, type Model, type ReleaseInfo, PublicEntryFailed, PublicEntryLoaded, + PublicEntryLoading, +} +import at_record_web/msg.{type Msg} +import at_record_web/pages/record.{ + condition, cover_fmt, detail_rows, notes, release_chips, +} +import at_record_web/ui/covers as cov +import at_record_web/ui/record_detail as rd +import gleam/option.{type Option, Some} +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html + +pub fn view(model: Model, handle: String) -> Element(Msg) { + case model.public_entry { + PublicEntryLoading -> loading_state() + PublicEntryFailed -> failed_state(handle) + PublicEntryLoaded(_, loaded_handle, entry, release_info) + if loaded_handle == handle + -> loaded_state(loaded_handle, entry, release_info) + PublicEntryLoaded(_, _, _, _) -> loading_state() + } +} + +fn loaded_state( + handle: String, + entry: Entry, + release_info: Option(ReleaseInfo), +) -> Element(Msg) { + let snap = entry.snapshot + html.div([attr.class("page-scroll")], [ + cov.detail_cover( + snap.title <> snap.artist_display, + snap.title, + entry.status, + cover_fmt(snap), + snap.thumb_url, + ), + titleblock(handle, entry), + html.div([attr.class("detail-body")], [ + condition(entry), + detail_rows(entry, snap, release_info), + release_chips(release_info), + notes(entry.notes), + ]), + ]) +} + +fn titleblock(handle: String, entry: Entry) -> Element(Msg) { + let snap = entry.snapshot + html.div([attr.class("titleblock")], [ + html.h1([attr.class("titleblock__title")], [text(snap.title)]), + html.span([attr.class("titleblock__artist")], [text(snap.artist_display)]), + html.span([attr.class("titleblock__via")], [ + text("From @" <> handle <> "'s crate"), + ]), + case entry.rating { + Some(value) -> rd.stars(value) + _ -> element.none() + }, + ]) +} + +fn loading_state() -> Element(Msg) { + html.div([attr.class("page-scroll")], [ + html.p([attr.class("loading-status")], [text("◌ FETCHING…")]), + ]) +} + +fn failed_state(handle: String) -> 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 record from @" <> handle <> "'s crate."), + ]), + ]), + ]) +} diff --git a/web/src/at_record_web/pages/record.gleam b/web/src/at_record_web/pages/record.gleam index 9f65d9c..0ab8273 100644 --- a/web/src/at_record_web/pages/record.gleam +++ b/web/src/at_record_web/pages/record.gleam @@ -4,13 +4,16 @@ import at_record/gen/defs.{type Snapshot} import at_record/gen/shelf/entry.{type ShelfEntry} -import at_record_web/model.{type Entry, type Model, type ReleaseInfo} +import at_record_web/model.{ + type Entry, type Model, type ReleaseInfo, LoggedIn, LoggedOut, +} import at_record_web/money import at_record_web/msg.{ - type Msg, AmendField, ArmRemove, CoverFileChosen, EntryAction, Rate, Regrade, - SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, + type Msg, AmendField, ArmRemove, CopyRecordLink, CoverFileChosen, EntryAction, + Rate, Regrade, SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, } import at_record_web/provenance +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 @@ -42,6 +45,7 @@ pub fn view(model: Model, entry: Entry) -> Element(Msg) { release_chips(model.release_info), notes(entry.notes), actions(entry.entry_id, model.editing, model.confirm_remove), + share_block(model, entry.entry_id), amend_panel(model), edit_panel(entry, model.editing, model.busy), timeline(model.timeline), @@ -49,7 +53,25 @@ pub fn view(model: Model, entry: Entry) -> Element(Msg) { ]) } -fn cover_fmt(snap: Snapshot) -> String { +/// Copy the public (no-auth) link to this same entry, so the owner can share +/// it; hidden if somehow rendered while logged out (never happens in +/// practice, since this page requires a session). +fn share_block(model: Model, entry_id: String) -> Element(Msg) { + case model.auth { + LoggedIn(handle) -> + html.div([attr.class("share-block")], [ + ctl.button("COPY SHARE LINK", ctl.Ghost, [ + event.on_click(CopyRecordLink(entry_id)), + ]), + html.span([attr.class("share-block__hint")], [ + text(route.to_path(model.PublicRecord(handle, entry_id))), + ]), + ]) + LoggedOut -> element.none() + } +} + +pub fn cover_fmt(snap: Snapshot) -> String { [snap.format, option.map(snap.year, int.to_string)] |> option.values |> string.join(" · ") @@ -87,7 +109,7 @@ fn provenance_line(entry: Entry) -> Element(Msg) { } } -fn condition(entry: Entry) -> Element(Msg) { +pub fn condition(entry: Entry) -> Element(Msg) { let grade = option.unwrap(entry.media_grade, "—") html.div([], [ frm.section_label("CONDITION"), @@ -108,7 +130,7 @@ fn sleeve_card(entry: Entry) -> option.Option(Element(Msg)) { }) } -fn detail_rows( +pub fn detail_rows( entry: Entry, snap: Snapshot, release_info: Option(ReleaseInfo), @@ -142,7 +164,7 @@ fn detail_rows( // Genres/styles from the resolved catalog release; hidden entirely when // there's nothing to show (no release resolved, or both lists are empty). -fn release_chips(release_info: Option(ReleaseInfo)) -> Element(Msg) { +pub fn release_chips(release_info: Option(ReleaseInfo)) -> Element(Msg) { let tags = case release_info { Some(r) -> list.append(r.genres, r.styles) None -> [] @@ -157,7 +179,7 @@ fn release_chips(release_info: Option(ReleaseInfo)) -> Element(Msg) { } } -fn notes(notes: Option(String)) -> Element(Msg) { +pub fn notes(notes: Option(String)) -> Element(Msg) { case notes { Some(body) -> rd.notes(body) None -> element.none() diff --git a/web/src/at_record_web/route.gleam b/web/src/at_record_web/route.gleam index 9022823..4bb77b0 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, Record, Scan, ScanDone, ScanReview, - Settings, + type Route, Add, Browse, Crate, EditInbox, PublicCrate, PublicRecord, Record, + Scan, ScanDone, ScanReview, Settings, } import gleam/uri.{type Uri} @@ -16,6 +16,8 @@ pub fn parse(target: Uri) -> Route { ["inbox"] -> EditInbox ["settings"] -> Settings ["record", entry_id] -> Record(entry_id) + ["u", handle] -> PublicCrate(handle) + ["u", handle, "record", entry_id] -> PublicRecord(handle, entry_id) _ -> Crate } } @@ -31,5 +33,7 @@ pub fn to_path(route: Route) -> String { EditInbox -> "/inbox" Settings -> "/settings" Record(entry_id) -> "/record/" <> entry_id + PublicCrate(handle) -> "/u/" <> handle + PublicRecord(handle, entry_id) -> "/u/" <> handle <> "/record/" <> entry_id } } diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 9062cbc..0602cf9 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -1,37 +1,39 @@ import at_record_web/effects.{ - add_item, apply_edit_proposal, browse_add, discogs_connect, discogs_disconnect, - discogs_import, discogs_import_wantlist, discogs_search, discogs_status, - entry_action, handle_search, load_avatar, load_browse, load_shelf, - load_shelf_more, load_timeline, logout, oauth_login, rate, regrade, - search_browse, + 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, } import at_record_web/model.{ type BrowseRelease, type Entry, type Model, type Notice, Add, Browse, Discogs, EditInbox, Entry, Failure, Form, InboxLoaded, InboxLoading, LoggedIn, LoggedOut, Model, Notice, ProposalApplied, ProposalApplying, ProposalReviewing, - Record, Scan, ScanDone, ScanReview, ShelfFailed, ShelfLoaded, ShelfLoading, - Success, Warning, blank_form, crate_window_size, + 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.{ type Msg, type ScanLookup, AcceptSuggestion, AmendField, AppliedProposal, ApplyProposal, ArmLogout, ArmRemove, ArtistSearch, BarcodeDetected, BatchItemDone, BrowseAdd, BrowseGenre, BrowseQuery, CameraUnsupported, - ClearNotice, ClearPhotoHint, CoverFileChosen, CoverUploaded, DisarmLogout, - DisarmRemove, DiscogsConnect, DiscogsDisconnect, DiscogsImport, + ClearNotice, ClearPhotoHint, CopyRecordLink, CoverFileChosen, CoverUploaded, + DisarmLogout, DisarmRemove, DiscogsConnect, DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, DiscogsLoadMore, DiscogsSearch, DiscogsVinylOnly, EntryAction, FormArtist, FormCounterparty, FormFolder, FormFormat, FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, FormStatus, FormTitle, FormYear, GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, - GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, GotShelf, - GotShelfMore, GotTimeline, HandleChanged, HandleSearch, IgnoreProposal, 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, + 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, + TriggerBrowseSearch, UseArtist, UseDiscogs, UseHandleSuggestion, } import at_record_web/photo_scan import at_record_web/route @@ -110,6 +112,14 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { EditInbox -> InboxLoading _ -> model.inbox }, + public_shelf: case route { + PublicCrate(_) -> PublicShelfLoading + _ -> model.public_shelf + }, + public_entry: case route { + PublicRecord(_, _) -> PublicEntryLoading + _ -> model.public_entry + }, ), effect.batch([ leaving_scan(model), @@ -120,6 +130,9 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { effect.batch([start_capture(scan.mode), effects.scan_seen()]) Browse -> load_browse() EditInbox -> effects.load_edit_inbox() + PublicCrate(handle) -> load_public_shelf(handle) + PublicRecord(handle, entry_id) -> + load_public_entry(handle, entry_id) _ -> effect.none() }, ]), @@ -1093,6 +1106,53 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { effects.persist_ignored_proposals(ignored), ) } + + GotPublicShelf(Ok(data)) -> #( + Model( + ..model, + public_shelf: PublicShelfLoaded(data.did, data.handle, data.items), + ), + effect.none(), + ) + GotPublicShelf(Error(_)) -> #( + Model(..model, public_shelf: PublicShelfFailed), + effect.none(), + ) + GotPublicEntry(Ok(data)) -> #( + Model( + ..model, + public_entry: PublicEntryLoaded( + data.did, + data.handle, + data.entry, + data.release, + ), + ), + effect.none(), + ) + GotPublicEntry(Error(_)) -> #( + Model(..model, public_entry: PublicEntryFailed), + effect.none(), + ) + + // 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(), + ) } } diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index b76aabf..310e69f 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -3,8 +3,8 @@ import at_record_web/model.{ type Entry, type Model, type Notice, type NoticeLevel, Add, Browse, Crate, - EditInbox, Failure, Info, LoggedIn, LoggedOut, Notice, Record, Scan, ScanDone, - ScanReview, Settings, Success, Warning, + EditInbox, Failure, Info, LoggedIn, LoggedOut, Notice, PublicCrate, + PublicRecord, Record, Scan, ScanDone, ScanReview, Settings, Success, Warning, } import at_record_web/msg.{type Msg, ClearNotice} import at_record_web/pages/add @@ -12,6 +12,8 @@ import at_record_web/pages/browse import at_record_web/pages/crate import at_record_web/pages/edit_inbox import at_record_web/pages/login +import at_record_web/pages/public_crate +import at_record_web/pages/public_record import at_record_web/pages/record import at_record_web/pages/scan import at_record_web/pages/scan_done @@ -29,10 +31,14 @@ import lustre/event pub fn view(model: Model) -> Element(Msg) { html.div([attr.class("app")], [ notice_view(model.notice), - case model.auth, model.busy { - LoggedOut, True -> login.restoring() - LoggedOut, False -> login.view(model) - LoggedIn(handle), _ -> authed_view(model, handle) + case model.route { + PublicCrate(_) | PublicRecord(_, _) -> public_view(model) + _ -> + case model.auth, model.busy { + LoggedOut, True -> login.restoring() + LoggedOut, False -> login.view(model) + LoggedIn(handle), _ -> authed_view(model, handle) + } }, ]) } @@ -41,6 +47,29 @@ fn authed_view(model: Model, handle: String) -> Element(Msg) { html.div([attr.class("shell")], [app_bar(model, handle), page(model)]) } +/// Read-only shell for `/u/:handle` routes: no login gate, since the crate +/// underneath is public network data regardless of who (if anyone) is +/// signed in on this browser. +fn public_view(model: Model) -> Element(Msg) { + html.div([attr.class("shell")], [public_app_bar(model), public_page(model)]) +} + +fn public_app_bar(model: Model) -> Element(Msg) { + case model.route { + PublicCrate(handle) -> back_bar("@" <> handle) + PublicRecord(_, _) -> back_bar("RECORD") + _ -> element.none() + } +} + +fn public_page(model: Model) -> Element(Msg) { + case model.route { + PublicCrate(handle) -> public_crate.view(model, handle) + PublicRecord(handle, _) -> public_record.view(model, handle) + _ -> element.none() + } +} + fn page(model: Model) -> Element(Msg) { case model.route { Crate -> crate.view(model) @@ -56,6 +85,8 @@ fn page(model: Model) -> Element(Msg) { Some(entry) -> record.view(model, entry) None -> crate.view(model) } + // Dispatched by `public_page` before `authed_view`/`page` are reached. + PublicCrate(_) | PublicRecord(_, _) -> crate.view(model) } } @@ -87,6 +118,8 @@ fn app_bar(model: Model, handle: String) -> Element(Msg) { EditInbox -> back_bar("EDIT INBOX") Settings -> back_bar("SETTINGS") Record(_) -> back_bar("RECORD") + // Dispatched by `public_app_bar` before `authed_view`/`app_bar` are reached. + PublicCrate(_) | PublicRecord(_, _) -> back_bar("RECORD") } } diff --git a/web/test/public_crate_test.gleam b/web/test/public_crate_test.gleam new file mode 100644 index 0000000..9fae181 --- /dev/null +++ b/web/test/public_crate_test.gleam @@ -0,0 +1,143 @@ +//// Public-route tests: URL <-> Route parsing for `/u/:handle` and +//// `/u/:handle/record/:entryId`, the login gate letting them render for a +//// logged-out visitor, and the update-loop wiring (route entry resets +//// state and kicks off the load, the two `Got*` messages land it, and the +//// owner's share-link copy button only fires while logged in). + +import at_record_web/model.{ + LoggedOut, Model, PublicCrate, PublicEntryFailed, PublicEntryLoaded, + PublicEntryLoading, PublicRecord, PublicShelfFailed, PublicShelfLoaded, + PublicShelfLoading, +} +import at_record_web/msg.{ + CopyRecordLink, GotPublicEntry, GotPublicShelf, LinkCopied, OnRouteChange, + PublicEntryData, PublicShelfData, +} +import at_record_web/route +import at_record_web/update.{update} +import at_record_web/view +import gleam/option.{None} +import gleam/string +import gleam/uri +import lustre/element +import support.{an_entry, base, empty_effect, logged_in, unauthorized} + +pub fn public_crate_route_round_trips_test() { + assert route.to_path(PublicCrate("alice.test")) == "/u/alice.test" + let assert Ok(target) = uri.parse("/u/alice.test") + assert route.parse(target) == PublicCrate("alice.test") +} + +pub fn public_record_route_round_trips_test() { + assert route.to_path(PublicRecord("alice.test", "e1")) + == "/u/alice.test/record/e1" + let assert Ok(target) = uri.parse("/u/alice.test/record/e1") + assert route.parse(target) == PublicRecord("alice.test", "e1") +} + +pub fn public_crate_renders_without_login_test() { + let model = + Model( + ..base(), + auth: LoggedOut, + route: PublicCrate("alice.test"), + public_shelf: PublicShelfLoaded("did:plc:a", "alice.test", [an_entry()]), + ) + let html = view.view(model) |> element.to_string + assert string.contains(html, "@alice.test") + assert string.contains(html, "Spiderland") + assert !string.contains(html, "LOG IN WITH ATPROTO") +} + +pub fn public_record_renders_without_login_test() { + let model = + Model( + ..base(), + auth: LoggedOut, + route: PublicRecord("alice.test", "e1"), + public_entry: PublicEntryLoaded( + "did:plc:a", + "alice.test", + an_entry(), + None, + ), + ) + let html = view.view(model) |> element.to_string + assert string.contains(html, "Spiderland") + assert string.contains(html, "alice.test") + assert !string.contains(html, "LOG IN WITH ATPROTO") +} + +pub fn on_route_change_to_public_crate_resets_and_loads_test() { + let stale = Model(..base(), public_shelf: PublicShelfFailed) + let #(model, effect) = update(stale, OnRouteChange(PublicCrate("bob.test"))) + assert model.route == PublicCrate("bob.test") + assert model.public_shelf == PublicShelfLoading + assert effect != empty_effect() +} + +pub fn on_route_change_to_public_record_resets_and_loads_test() { + let stale = Model(..base(), public_entry: PublicEntryFailed) + let #(model, effect) = + update(stale, OnRouteChange(PublicRecord("bob.test", "e9"))) + assert model.route == PublicRecord("bob.test", "e9") + assert model.public_entry == PublicEntryLoading + assert effect != empty_effect() +} + +pub fn got_public_shelf_ok_loads_items_test() { + let #(model, _) = + update( + base(), + GotPublicShelf( + Ok( + PublicShelfData(did: "did:plc:a", handle: "alice.test", items: [ + an_entry(), + ]), + ), + ), + ) + assert model.public_shelf + == PublicShelfLoaded("did:plc:a", "alice.test", [an_entry()]) +} + +pub fn got_public_shelf_error_marks_failed_test() { + let #(model, _) = update(base(), GotPublicShelf(Error(unauthorized()))) + assert model.public_shelf == PublicShelfFailed +} + +pub fn got_public_entry_ok_loads_entry_test() { + let #(model, _) = + update( + base(), + GotPublicEntry( + Ok(PublicEntryData( + did: "did:plc:a", + handle: "alice.test", + entry: an_entry(), + release: None, + )), + ), + ) + assert model.public_entry + == PublicEntryLoaded("did:plc:a", "alice.test", an_entry(), None) +} + +pub fn got_public_entry_error_marks_failed_test() { + let #(model, _) = update(base(), GotPublicEntry(Error(unauthorized()))) + assert model.public_entry == PublicEntryFailed +} + +pub fn copy_record_link_only_fires_when_logged_in_test() { + let #(_, logged_out_effect) = update(base(), CopyRecordLink("e1")) + assert logged_out_effect == empty_effect() + let #(_, logged_in_effect) = update(logged_in(), CopyRecordLink("e1")) + assert logged_in_effect != empty_effect() +} + +pub fn link_copied_sets_a_notice_either_way_test() { + let #(ok_model, _) = update(base(), LinkCopied(True)) + assert ok_model.notice != None + let #(fail_model, _) = update(base(), LinkCopied(False)) + assert fail_model.notice != None +} diff --git a/web/test/record_test.gleam b/web/test/record_test.gleam index d678ef0..acb04f2 100644 --- a/web/test/record_test.gleam +++ b/web/test/record_test.gleam @@ -130,3 +130,9 @@ pub fn route_change_clears_release_info_test() { let #(model, _) = update(seeded, OnRouteChange(model.Crate)) assert model.release_info == None } + +pub fn record_view_shows_a_copy_share_link_button_with_the_public_path_test() { + let html = record.view(logged_in(), an_entry()) |> element.to_string + assert string.contains(html, "COPY SHARE LINK") + assert string.contains(html, "/u/alice.test/record/e1") +} diff --git a/web/test/support.gleam b/web/test/support.gleam index 701db11..c0cd967 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -59,6 +59,8 @@ pub fn base() -> Model { inbox: model.InboxLoading, confirm_logout: False, ignored_proposals: [], + public_shelf: model.PublicShelfLoading, + public_entry: model.PublicEntryLoading, ) } -- 2.51.2