import crate/gen/catalog/edit as catalog_edit import crate/gen/catalog/list_edit_proposals import crate/gen/catalog/list_releases import crate/gen/catalog/release as catalog_release import crate/gen/defs.{type Snapshot} import crate/gen/discogs/import_collection as discogs_import_collection import crate/gen/discogs/resolve_barcode as discogs_resolve_barcode import crate/gen/discogs/search_artists as discogs_search_artists import crate/gen/discogs/search_releases as discogs_search_releases import crate/gen/feed/get_feed_skeleton.{type FeedItem} import crate/gen/shelf/entry.{type ShelfEntry} import crate/gen/shelf/list_entries import gleam/dict import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/order import gleam/result import gleam/set import gleam/string import gleam/uri pub type Auth { LoggedOut LoggedIn(handle: String) } /// The active page (URL-routed via modem). pub type Route { Crate Add Scan ScanReview ScanDone Record(entry_id: String) /// The record's amend fold-out, promoted to its own routed screen so the /// form has a URL and back/forward work like any other drill-down. RecordAmend(entry_id: String) Browse Feed ConnectionsFollowing ConnectionsFollowers EditInbox /// One proposal's detail screen, off `EditInbox`. EditProposalDetail(id: String) 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) /// The shared catalog's view of one release, reached off a browse card. PressingDetail(did: String, rkey: String) } pub type Connection { Connection( did: String, handle: String, viewer_follows: Bool, follows_viewer: Bool, mutual: Bool, ) } pub type ConnectionsState { ConnectionsLoading ConnectionsLoaded( items: List(Connection), cursor: Option(String), complete: Bool, relationship_complete: Bool, ) ConnectionsFailed } /// Identity of one connections list request. The generation is bumped for /// every reload or page fetch, so a response from an earlier request cannot /// replace a newer copy of the same route. pub type ConnectionsRequest { ConnectionsRequest(route: Route, generation: Int, cursor: Option(String)) } /// Identity of one optimistic follow write. Keeping the route and generation /// with the DID prevents a completion from an earlier visit from changing the /// current visit's row. pub type ConnectionWrite { ConnectionWrite(route: Route, generation: Int, did: String, desired: Bool) } /// How many crate items render before the LOAD MORE button, and the amount /// each LOAD MORE reveals. Purely a render cap: the server may have fetched /// more than this already (see `crate_page_limit`), in which case pressing /// LOAD MORE just widens the window with no network round-trip; only once /// the window catches up to everything fetched does LOAD MORE fetch the /// next page via `crate_cursor`. pub const crate_window_size = 30 /// The page size requested per `shelf.listEntries` fetch (initial load and /// each cursor-driven LOAD MORE fetch): a few window-fuls, so most LOAD MORE /// presses are the free, local `crate_window` bump above rather than a /// network fetch. pub const crate_page_limit = 60 /// How many feed items to request per `feed.getFeedSkeleton` fetch (initial /// load and each infinite-scroll page). pub const feed_page_limit = 30 /// Releases requested per shared-catalog page. pub const browse_page_limit = 30 /// How long an armed two-tap confirm (REMOVE, LOG OUT) stays armed before /// auto-disarming if the second tap never comes. pub const confirm_timeout_ms = 4000 /// DOM id of the record-detail REMOVE button, so the outside-click watcher /// can tell a tap on it apart from a tap elsewhere. pub const remove_button_id = "record-remove-btn" /// DOM id of the settings DISCONNECT (Discogs) button, same reasoning as /// `remove_button_id`. pub const discogs_disconnect_button_id = "settings-discogs-disconnect-btn" /// DOM id of the settings LOG OUT button, same reasoning as `remove_button_id`. pub const logout_button_id = "settings-logout-btn" /// The crate page's cover layout, persisted via `prefs.display_key` so it /// survives a reload. pub type Display { Grid Rows } pub fn display_to_string(display: Display) -> String { case display { Grid -> "grid" Rows -> "rows" } } /// Anything other than a recognised value (including "never set") falls back /// to Grid, today's pre-persistence default. pub fn display_from_string(value: String) -> Display { case value { "rows" -> Rows _ -> Grid } } pub type Theme { Light Dark System } pub fn theme_to_string(theme: Theme) -> String { case theme { Light -> "light" Dark -> "dark" System -> "system" } } pub fn theme_from_string(value: String) -> Theme { case value { "light" -> Light "dark" -> Dark _ -> System } } /// A folded crate entry: the `shelf.listEntries` output row, generated from /// the lexicon and adopted directly as the web model. The BFF folds the event /// log into one of these per entry. pub type Entry = list_entries.Entry /// The entry's snapshot. The lexicon types it `Option` (a listEntries row need /// not carry one), but the BFF always folds one in, so the views read it /// unwrapped through here; the fallback empty snapshot is only ever a defence /// against a malformed response, never a value the UI is expected to render. pub fn entry_snapshot(entry: Entry) -> Snapshot { case entry.snapshot { Some(snapshot) -> snapshot None -> defs.Snapshot( artist_display: "", cover: None, format: None, thumb_url: None, title: "", year: None, ) } } /// Whose crate a shelf load is for: the session's own account, or another /// actor (carrying that actor's resolved did/handle for the public views). pub type Owner { Own Actor(did: String, handle: String) } /// A shelf's load state, so a failed fetch is distinguishable from a genuinely /// empty crate. One type for both the session's own crate and any actor's /// public one; `ShelfLoaded` carries the `Owner` it was loaded for. pub type Shelf { ShelfLoading ShelfLoaded(owner: Owner, items: List(Entry)) ShelfFailed } /// Which cached `Shelf` a crate load belongs to: the session's own account, /// or an actor reached off a route. Routes carry handles, not dids, so the /// actor arm keys on handle alone (the loaded `Shelf` itself still carries /// the resolved did via `Owner`). Nothing ever evicts a key: the cache is /// unbounded for the session's lifetime, by design. pub type CrateKey { OwnCrate ActorCrate(handle: String) } /// The cached `Shelf` for `key`, or `ShelfLoading` if nothing has been /// fetched under it yet this session. pub fn crate_of(model: Model, key: CrateKey) -> Shelf { case dict.get(model.crates, key) { Ok(shelf) -> shelf Error(Nil) -> ShelfLoading } } /// Store `shelf` under `key`, leaving every other cached crate untouched. pub fn set_crate(model: Model, key: CrateKey, shelf: Shelf) -> Model { Model(..model, crates: dict.insert(model.crates, key, shelf)) } /// Set `key` to `ShelfLoading` only if nothing is cached there yet, so /// revisiting an already-loaded crate keeps rendering the stale copy while /// the route's own fetch effect refreshes it in the background. pub fn set_crate_loading_if_absent(model: Model, key: CrateKey) -> Model { case dict.has_key(model.crates, key) { True -> model False -> set_crate(model, key, ShelfLoading) } } /// The loaded own-crate entries, or [] while loading or after a failed load. pub fn entries(model: Model) -> List(Entry) { case crate_of(model, OwnCrate) { ShelfLoaded(_, items) -> items _ -> [] } } /// The signed-in viewer's overlap with another actor's public crate. pub type CrateOverlap { CrateOverlap( common_count: Int, both_wanted_title: Option(String), want_match_count: Int, viewer_follows: Bool, follow_uri: Option(String), ) } /// One entry's superset: the crate owner's did/handle, the folded current /// entry, its full event timeline, and its resolved release fields. Fed by the /// unified `getEntry` response and shared by the own/public record pages and /// the feed hydration cache. pub type EntryDetail { EntryDetail( did: String, handle: String, entry: Entry, events: List(ShelfEntry), release_info: Option(ReleaseInfo), ) } /// The open record's detail load state, mirroring `Shelf`. Serves both the /// authed record page and the public one (routes are mutually exclusive). pub type EntryDetailState { EntryDetailLoading EntryDetailLoaded(EntryDetail) EntryDetailFailed } /// The network activity feed's load state, mirroring `Shelf`. `FeedLoaded` /// carries the raw skeleton items; each item's entry refs are hydrated /// separately into `Model.feed_entries`. pub type FeedState { FeedLoading FeedLoaded(items: List(FeedItem), cursor: Option(String), fallback: Bool) FeedFailed } /// Identity of one feed skeleton or pagination request. The generation is /// bumped when the feed route changes and when a page fetch starts, so an /// earlier response cannot replace or append to the current feed. pub type FeedRequest { FeedRequest(route: Route, generation: Int, cursor: Option(String)) } /// The day-separator label for a feed item: the newest hydrated entry's /// `updatedAt` day (ISO dates sort lexicographically), or `None` while none /// of its refs are hydrated yet. pub fn feed_item_day( item: FeedItem, cache: dict.Dict(#(String, String), EntryDetail), ) -> Option(String) { item.entries |> list.filter_map(fn(ref) { dict.get(cache, #(ref.actor, ref.entry_id)) |> result.map(fn(fe) { string.slice(fe.entry.updated_at, 0, 10) }) }) |> list.fold(None, fn(newest, day) { case newest { None -> Some(day) Some(current) -> case string.compare(day, current) { order.Lt -> newest _ -> Some(day) } } }) } /// The first entry ref's `#(actor, entry_id)` pair, the item's identity for /// pagination de-dup; `None` only for a defensively-empty item. pub fn feed_item_key(item: FeedItem) -> Option(#(String, String)) { case item.entries { [first, ..] -> Some(#(first.actor, first.entry_id)) [] -> None } } /// The items from `incoming` whose key doesn't already appear in `existing`: /// a write can land between pages, so the next fetched page may repeat an /// item already folded into the list. An item with no key (`key_of` returns /// `None`) is always kept, on the assumption it can't be reliably deduped. pub fn append_new( existing: List(a), incoming: List(a), key_of: fn(a) -> Option(b), ) -> List(a) { let seen = existing |> list.filter_map(fn(item) { key_of(item) |> option.to_result(Nil) }) |> set.from_list list.filter(incoming, fn(item) { case key_of(item) { Some(key) -> !set.contains(seen, key) None -> True } }) } /// How prominent/urgent a notice is; drives its icon and colour. pub type NoticeLevel { Success Failure Info Warning } pub type Notice { Notice(level: NoticeLevel, message: String) /// A permission failure with an explicit action. This is intentionally /// distinct from a generic warning so it cannot silently reauthorize an /// existing OAuth session. ReauthorizeNotice(message: String) } /// Why a form is showing an error, which is also whether RETRY means /// anything: a rejected write can succeed on a second attempt, a rejected /// input cannot until the visitor changes it. pub type FormError { InvalidInput(message: String) WriteFailed(message: String) } pub type Form { Form( title: String, artist: String, format: String, year: String, status: String, sleeve_grade: String, rating: String, folder: String, price_amount: String, price_currency: String, counterparty: String, thumb_url: String, cover_url: String, discogs_id: String, ) } pub fn blank_form() -> Form { Form( title: "", artist: "", format: "", year: "", status: "owned", sleeve_grade: "", rating: "", folder: "", price_amount: "", price_currency: "EUR", counterparty: "", thumb_url: "", cover_url: "", discogs_id: "", ) } /// A Discogs search hit as a ready-to-submit add form (owned by default). pub fn form_from_discogs(result: DiscogsResult) -> Form { Form( ..blank_form(), title: result.title, artist: result.artist, format: option.unwrap(result.format, ""), year: option.map(result.year, int.to_string) |> option.unwrap(""), thumb_url: option.unwrap(result.thumb_url, ""), cover_url: option.unwrap(result.cover_url, ""), discogs_id: int.to_string(result.discogs_id), ) } /// An atproto directory match for the login handle field. pub type HandleSuggestion { HandleSuggestion( did: String, handle: String, display_name: Option(String), avatar: Option(String), ) } /// A Discogs search hit, normalized by the server for the add form: the /// `discogs.searchReleases` row, generated from the lexicon and adopted /// directly as the web model. pub type DiscogsResult = discogs_search_releases.DiscogsRelease /// One page of Discogs search results plus its position in the full set. pub type DiscogsSearchPage { DiscogsSearchPage(results: List(DiscogsResult), page: Int, pages: Int) } /// A Discogs artist search hit for the artist-scoped autocomplete; `id` is /// the raw Discogs artist id, carried for later catalog.artist linking. The /// `discogs.searchArtists` row, generated from the lexicon and adopted /// directly as the web model. pub type ArtistHit = discogs_search_artists.Artist /// Display-relevant fields off the entry's resolved catalog release. pub type ReleaseInfo { ReleaseInfo( artist_display: Option(String), genres: List(String), styles: List(String), country: Option(String), released: Option(String), ) } /// A "did you mean" candidate for a barcode that resolved to nothing, with a /// 0-100 confidence the server scored it at. The `discogs.resolveBarcode` /// suggestion row, generated from the lexicon and adopted directly as the /// web model. pub type Suggestion = discogs_resolve_barcode.Suggestion /// A release already known to the shared crate catalog, found before any /// Discogs lookup was made; `publisher_handle` is best-effort, mirroring how /// `BrowseRelease` carries its own. The `discogs.resolveBarcode` network /// match, generated from the lexicon and adopted directly as the web model. pub type NetworkMatch = discogs_resolve_barcode.NetworkMatch /// A scan row's add-eligible item: either a Discogs match (added via the /// manual add path) or a network match (added via `adoptRelease`, same as /// the browse grid's quick actions). `Adding` carries whichever one /// triggered the write, so both flows share one in-flight state. pub type ScanItem { FromDiscogs(DiscogsResult) FromNetwork(NetworkMatch) } /// One scanned barcode's outcome, from lookup through commit. `Failed` keeps /// its item so RETRY can re-fire the write without re-running the lookup. pub type ScanOutcome { Pending Matched(result: DiscogsResult, owned: Bool) NetworkMatched(release: NetworkMatch) NoMatch(suggestions: List(Suggestion)) Adding(item: ScanItem) Failed(item: ScanItem) Added } pub type ScanRow { ScanRow(barcode: String, outcome: ScanOutcome) } /// The scan page's capture mode: continuous live detection or tap-to-shoot. pub type ScanMode { LiveMode PhotoMode } /// Live wins when available; photo is the default only where the /// live-detect loop cannot run. pub fn default_scan_mode(live: Bool, photo: Bool) -> ScanMode { case live, photo { False, True -> PhotoMode _, _ -> LiveMode } } /// Barcode-scan session state. Rows are newest first; owned discogs ids are /// fetched once per session; `import_old_count` snapshots the crate size when /// a batch run starts so the done page can show old -> new; `last_detected` /// guards against the live loop or a re-shot photo firing the same code twice /// in a row (e.g. it's still in frame right after its row was removed). pub type ScanState { ScanState( mode: ScanMode, photo_supported: Bool, photo_hint: Option(String), camera_error: Option(String), seen_ids: set.Set(String), rows: List(ScanRow), importing: Bool, import_old_count: Int, import_added: Int, last_detected: Option(String), ) } pub fn blank_scan() -> ScanState { ScanState( mode: LiveMode, photo_supported: False, photo_hint: None, camera_error: None, seen_ids: set.new(), rows: [], importing: False, import_old_count: 0, import_added: 0, last_detected: None, ) } /// Rows a batch run picks up, and the "IMPORT N -> CRATE" count. `Failed` is /// excluded so the bulk button can't loop on a failing row; RETRY is its only /// path back in. pub fn scan_importable(rows: List(ScanRow)) -> List(ScanRow) { list.filter(rows, fn(row) { case row.outcome { Matched(owned: False, ..) | NetworkMatched(_) | Adding(_) -> True _ -> False } }) } /// What the amend page's write path is doing right now: idle, uploading a /// cover photo (fires immediately on file choice), or publishing the typed /// draft fields. One field instead of the old `publishing`/`busy` pair, so /// the submit button's disabled state and its label always agree. pub type AmendActivity { AmendIdle AmendUploadingCover AmendPublishing } /// Draft of a manual catalog amendment (all free text; lists comma-split). pub type AmendDraft { AmendDraft( title: String, released: String, country: String, genres: String, styles: String, refresh_cover: Bool, ) } pub fn blank_amend() -> AmendDraft { AmendDraft( title: "", released: "", country: "", genres: "", styles: "", refresh_cover: False, ) } /// One catalog release as shown on the browse grid, folded against the /// viewer's own crate (`owned`/`wanted`): the `catalog.listReleases` row, /// generated from the lexicon and adopted directly as the web model. pub type BrowseRelease = list_releases.ReleaseRow /// One browse-card quick add: everything `catalog.adoptRelease` needs, carried /// through the write so neither the response nor a retry has to re-derive the /// status from a model slot the page may have cleared meanwhile. pub type BrowseAddRequest { BrowseAddRequest(uri: String, cid: String, status: String) } /// A quick add that failed, paired with the message its card shows; the /// request is the one its RETRY re-fires. pub type BrowseAddFailure { BrowseAddFailure(request: BrowseAddRequest, message: String) } /// 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 = catalog_release.collection 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). /// The `catalog.edit#releaseFields` shape, generated from the lexicon and /// adopted directly as the web model. It also carries a cover blob and a /// thumb_url the inbox views have no use for (they only diff text/list /// fields); those two fields simply go unread here. pub type ProposalFields = catalog_edit.ReleaseFields fn blank_proposal_fields() -> ProposalFields { catalog_edit.ReleaseFields( country: None, cover: None, genres: None, released: None, styles: None, thumb_url: None, title: None, ) } /// One `catalog.edit` proposal against one of the caller's own releases: the /// `catalog.listEditProposals` row, generated from the lexicon and adopted /// directly as the web model. pub type EditProposal = list_edit_proposals.ProposalRow /// The target release's fields as they stood when the proposal was filed. /// The lexicon types it `Option` (a row need not carry one), but the BFF /// always folds one in, so views read it unwrapped through here; the /// fallback blank fields value is only ever a defence against a malformed /// response, never a value the UI is expected to render. pub fn proposal_current(proposal: EditProposal) -> ProposalFields { case proposal.current { Some(fields) -> fields None -> blank_proposal_fields() } } /// The proposal's proposed fields, unwrapped the same way as `proposal_current`. pub fn proposal_changes(proposal: EditProposal) -> ProposalFields { case proposal.fields { Some(fields) -> fields None -> blank_proposal_fields() } } /// A proposal card's local lifecycle: reviewing, an apply write in flight, or /// applied (the release uri backs the "Applied: ..." confirmation strip). /// Ignoring a card removes it from `Inbox` outright rather than tagging it /// with a status - there is nothing further to render for it. pub type ProposalStatus { ProposalReviewing ProposalApplying ProposalApplied(release_uri: String) } pub type InboxCard { InboxCard(proposal: EditProposal, status: ProposalStatus) } /// The edit inbox's load state, so a failed fetch is distinguishable from a /// genuinely empty inbox (mirrors `Shelf`). Loading only starts when the /// inbox page itself is opened - never at login. pub type Inbox { InboxLoading InboxLoaded(List(InboxCard)) InboxFailed } /// Cards still awaiting a decision, for both the page's intro count and the /// settings EDIT INBOX badge; applied/ignored cards don't count (ignored ones /// are gone from the model, applied ones are resolved). pub fn inbox_pending_count(inbox: Inbox) -> Int { case inbox { InboxLoaded(cards) -> cards |> list.filter(fn(c) { c.status == ProposalReviewing }) |> list.length InboxLoading | InboxFailed -> 0 } } /// The `EditProposalDetail` route's path id for a proposal: its own `uri` is /// an AT-URI (slashes, colons), so it's percent-encoded to survive as a /// single path segment. pub fn proposal_route_id(proposal_uri: String) -> String { uri.percent_encode(proposal_uri) } /// DOM id of a proposal's APPLY/ADOPT confirm button, so the outside-click /// watcher can tell a tap on it apart from a tap on any other card's. pub fn apply_button_id(proposal_uri: String) -> String { "apply-btn-" <> proposal_route_id(proposal_uri) } /// The inbox card whose proposal resolves to this route id, if any. pub fn find_proposal_card(inbox: Inbox, id: String) -> Option(InboxCard) { case inbox { InboxLoaded(cards) -> cards |> list.find(fn(c) { proposal_route_id(c.proposal.uri) == id }) |> option.from_result InboxLoading | InboxFailed -> None } } /// Locally-ignored `catalog.edit` proposals, as uri+cid pairs (the same /// strongRef shape the proposal itself carries), persisted client-side only /// via `prefs.ignored_proposals_key` - a repo record would make "ignored" /// public data, which this v1 deliberately avoids. pub const max_ignored_proposals = 200 /// True when `uri`+`cid` names an ignored proposal; a proposal re-filed with /// a different cid is not covered by an older ignore. pub fn is_ignored_proposal( ignored: List(#(String, String)), uri: String, cid: String, ) -> Bool { list.contains(ignored, #(uri, cid)) } /// Record `uri`+`cid` as ignored (deduped on uri, so re-ignoring the same /// proposal just moves it to the end) and cap the list to the most recent /// `max_ignored_proposals` entries so it can't grow unbounded. pub fn add_ignored_proposal( ignored: List(#(String, String)), uri: String, cid: String, ) -> List(#(String, String)) { let deduped = list.filter(ignored, fn(pair) { pair.0 != uri }) let updated = list.append(deduped, [#(uri, cid)]) case list.length(updated) > max_ignored_proposals { True -> list.drop(updated, list.length(updated) - max_ignored_proposals) False -> updated } } /// The outcome of one capped collection-import run: the /// `discogs.importCollection`/`discogs.importWantlist` response, generated /// from the lexicon and adopted directly as the web model. pub type ImportRun = discogs_import_collection.ImportRun pub type Model { Model( auth: Auth, route: Route, login_handle: String, login_suggestions: List(HandleSuggestion), // The raw OAuth error code from a failed login redirect, mapped to copy // on the signed-out page. None once the user edits the handle or retries. login_error: Option(String), view: String, display: Display, theme: Theme, // Cached crate loads, keyed by whose crate it is: the session's own // (`OwnCrate`) or an actor's public one (`ActorCrate`). crates: dict.Dict(CrateKey, Shelf), // How many crate items are currently revealed (client-side windowing). crate_window: Int, // The current view's next-page cursor; `None` means either nothing has // loaded yet or the view has no more pages. crate_cursor: Option(String), // True only while a cursor-driven LOAD MORE fetch is in flight. shelf_loading_more: Bool, // Entry id -> the handle a foreign (adopted) release was minted by. via_handles: dict.Dict(String, String), form: Form, // The Add page's inline failure, shown above SAVE TO CRATE without // touching `form` itself, so a rejection never costs the fields the // visitor already typed. form_error: Option(FormError), notice: Option(Notice), busy: Bool, // The pre-optimistic-update copy of an entry mid-Rate/Regrade, restored // if the write fails. None means no optimistic entry edit is in flight. pending_revert: Option(Entry), selected: Option(String), editing: Bool, confirm_remove: Bool, // The open record's superset detail (own or public); fed by getEntry. entry_detail: EntryDetailState, discogs: Discogs, scan: ScanState, // What the amend page's own write path is doing right now; drives both // the PUBLISH AMENDMENT button's disabled state and its label from one // source instead of two. amend_activity: AmendActivity, // The amend page's inline write-failure message (publish or cover // upload), shown above PUBLISH AMENDMENT; the draft fields themselves // are never touched by a failure. amend_error: Option(String), amend: AmendDraft, avatar: Option(String), browse: List(BrowseRelease), // True while the grid's own (re)load is in flight; page-scoped, so a // browse response can never leave another page's controls disabled. browse_loading: Bool, // The current filtered catalog's next-page cursor; `None` means there is // no next page or a changed filter has invalidated the prior cursor. browse_cursor: Option(String), browse_loading_more: Bool, // The quick-add write in flight; None means idle. browse_adding: Option(BrowseAddRequest), // The last quick-add that failed, so its card alone shows the inline retry // instead of relying on the global toast; cleared on the next add attempt // (any card) or a fresh grid load. browse_add_error: Option(BrowseAddFailure), // The free-text/genre search box above the browse grid; both empty means // the default live grid from `load_browse`, not a search result. browse_query: String, browse_genre: String, // The genre filter panel's open/collapsed state; collapsed by default. browse_filters_open: Bool, inbox: Inbox, // Armed like `confirm_remove`: first tap arms, second tap executes. confirm_logout: Bool, // The uri of the proposal currently armed for APPLY/ADOPT (inbox card or // proposal detail, whichever fired the arm), same two-tap reasoning as // `confirm_remove`; `None` means nothing armed. A single field rather // than per-card state since only one proposal can be armed at a time. confirm_apply: Option(String), // Locally-ignored edit-proposal uri+cid pairs, loaded from // `prefs.ignored_proposals_key` at init; see `add_ignored_proposal`. ignored_proposals: List(#(String, String)), // The signed-in viewer's overlap with the public crate currently open. overlap: Option(CrateOverlap), // The pressing detail page's release, for the /pressing/:did/:rkey route. pressing: PressingState, // The network feed's load state, for the /feed route. feed: FeedState, feed_generation: Int, connections: ConnectionsState, connections_generation: Int, connection_write_generation: Int, connection_pending: Option(ConnectionWrite), connections_loading_more: Bool, // Hydrated feed entry refs, keyed #(actor_did, entry_id). feed_entries: dict.Dict(#(String, String), EntryDetail), // True only while a cursor-driven feed page fetch is in flight. feed_loading_more: Bool, // How many client-side route changes have happened this session; 0 means // still on the route the app booted into. Drives `Back`'s choice between // real browser history and a fallback push (see `route.section`'s // sibling logic in update.gleam). nav_depth: Int, ) } /// Which Discogs import a run is; only one import can be in flight at a time, /// and the button that started it is the only one labelled IMPORTING…. pub type ImportKind { ImportCollection ImportWantlist } /// Discogs seed/import state: the autocomplete query and its paged results, /// the connected account, and which of the two import runs (if any) is in /// flight. pub type Discogs { Discogs( query: String, results: List(DiscogsResult), searching: Bool, vinyl_only: Bool, page: Int, pages: Int, username: Option(String), importing: Option(ImportKind), // Armed like `confirm_remove`: first tap arms, second tap executes. confirm_disconnect: Bool, // The last import/disconnect failure, shown inline on the Discogs card // rather than only as a transient global notice; cleared on the next // attempt or a successful one. error: Option(String), artist_query: String, artist_results: List(ArtistHit), artist_id: Option(String), ) } pub fn blank_discogs() -> Discogs { Discogs( query: "", results: [], searching: False, vinyl_only: True, page: 1, pages: 0, username: None, importing: None, confirm_disconnect: False, error: None, artist_query: "", artist_results: [], artist_id: None, ) }