//// Root view: the phone shell (app bar) and route dispatch to the page //// modules. import crate_web/model.{ type Model, type Notice, type NoticeLevel, Add, Browse, Crate, EditInbox, EditProposalDetail, EntryDetailFailed, EntryDetailLoaded, Failure, Feed, Info, LoggedIn, LoggedOut, Notice, PressingDetail, PublicCrate, PublicRecord, Record, RecordAmend, Scan, ScanDone, ScanReview, Settings, Success, Warning, inbox_pending_count, } import crate_web/msg.{type Msg, Back, ClearNotice} import crate_web/pages/add import crate_web/pages/browse import crate_web/pages/crate import crate_web/pages/edit_inbox import crate_web/pages/edit_proposal import crate_web/pages/feed import crate_web/pages/login import crate_web/pages/pressing import crate_web/pages/public_crate import crate_web/pages/public_record import crate_web/pages/record import crate_web/pages/record_amend import crate_web/pages/scan import crate_web/pages/scan_done import crate_web/pages/scan_review import crate_web/pages/settings import crate_web/route import crate_web/ui/app_bar as bar import crate_web/ui/nav import crate_web/ui/record_detail as rd import crate_web/ui/states import gleam/option.{type Option, None, Some} import lustre/attribute as attr import lustre/element.{type Element, text} import lustre/element/html import lustre/event pub fn view(model: Model) -> Element(Msg) { html.div([attr.class("app")], [ notice_view(model.notice), case model.route { PublicCrate(_) | PublicRecord(_, _) -> public_view(model) _ -> case model.auth, model.busy { LoggedOut, True -> login.restoring() LoggedOut, False -> login.view(model) LoggedIn(_), _ -> authed_view(model) } }, ]) } fn authed_view(model: Model) -> Element(Msg) { html.div([attr.class("shell")], [ // Crate home folds the masthead into its own hero band instead of // stacking a second chrome band above it (crate.gleam's `hero_bar`). case model.route { Crate -> element.none() _ -> nav.masthead() }, page(model), bottom_bar(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) { case model.route { // The record page is a back-bar drill-down: wrap it in `.sub-page` so the // app-bar stays fixed-height chrome and only the body flexes, instead of // both splitting the shell height (which left a tall empty band on top). PublicRecord(_, _) -> html.div([attr.class("shell")], [ html.div([attr.class("sub-page")], [ public_app_bar(model), public_page(model), ]), ]) _ -> html.div([attr.class("shell")], [ public_app_bar(model), public_page(model), ]) } } fn public_app_bar(model: Model) -> Element(Msg) { case model.route { // Public-crate chrome is inset in its hero band (public_crate.hero). PublicCrate(_) -> element.none() PublicRecord(_, _) -> bar.app_bar( bar.icon_link("←", "Back", "/"), bar.bar_title("RECORD"), element.none(), ) _ -> 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() } } /// Every screen but the four tab destinations is a drill-down reached by /// tapping into something (a record, the scan/add flow, an inbox proposal), /// so it keeps its own back+title header instead of one of the bottom tabs /// tracking it as active. fn page(model: Model) -> Element(Msg) { case model.route { Crate -> crate.view(model) Browse -> browse.view(model) Feed -> feed.view(model) Settings -> settings.view(model) Add -> sub_page("ADD RECORD", element.none(), add.view(model)) Scan -> sub_page( "SCAN BARCODE", bar.icon_link("✎", "Add manually", route.to_path(Add)), scan.view(model), ) ScanReview -> sub_page_to( "REVIEW YOUR HAUL", route.to_path(Scan), element.none(), scan_review.view(model), ) ScanDone -> sub_page("IMPORT COMPLETE", element.none(), scan_done.view(model)) EditInbox -> sub_page("EDIT INBOX", element.none(), edit_inbox.view(model)) EditProposalDetail(id) -> sub_page_to( "SUGGESTED FIX", route.to_path(EditInbox), element.none(), edit_proposal_page(model, id), ) Record(entry_id) -> case model.entry_detail { EntryDetailLoaded(detail) if detail.entry.entry_id == entry_id -> sub_page("RECORD", element.none(), record.view(model, detail)) EntryDetailFailed -> sub_page( "RECORD", element.none(), states.failed_page("Couldn't load that record right now."), ) _ -> sub_page("RECORD", element.none(), states.loading_page()) } RecordAmend(entry_id) -> sub_page_to( amend_title(model, entry_id), route.to_path(Record(entry_id)), element.none(), record_amend.view(model), ) PressingDetail(_, _) -> sub_page("PRESSING", element.none(), pressing.view(model)) // Dispatched by `public_page` before `authed_view`/`page` are reached. PublicCrate(_) | PublicRecord(_, _) -> crate.view(model) } } fn sub_page( title: String, right: Element(Msg), body: Element(Msg), ) -> Element(Msg) { sub_page_to(title, "/", right, body) } /// `back_href` is the fallback destination `Back` pushes to when there's no /// in-app history to return to (a direct-loaded drill-down); otherwise `Back` /// just steps the browser back one entry. fn sub_page_to( title: String, back_href: String, right: Element(Msg), body: Element(Msg), ) -> Element(Msg) { html.div([attr.class("sub-page")], [ bar.app_bar( bar.icon_button("←", "Back", Back(back_href)), bar.bar_title(title), right, ), body, ]) } fn amend_title(model: Model, entry_id: String) -> String { rd.amend_action_label(model.via_handles, entry_id, "AMEND PRESSING") } fn edit_proposal_page(model: Model, id: String) -> Element(Msg) { case model.inbox { model.InboxLoaded(_) -> case model.find_proposal_card(model.inbox, id) { Some(card) -> edit_proposal.view(card) // Unknown/expired id (stale link, or already ignored elsewhere): // fall back to the list rather than a dead end. None -> edit_inbox.view(model) } model.InboxLoading -> element.none() model.InboxFailed -> edit_inbox.view(model) } } fn bottom_bar(model: Model) -> Element(Msg) { let section = route.section(model.route) nav.bottom_bar([ nav.tab("CRATE", route.to_path(Crate), section == Crate, 0), nav.tab("BROWSE", route.to_path(Browse), section == Browse, 0), nav.add_tab(route.to_path(Scan)), nav.tab("FEED", route.to_path(Feed), section == Feed, 0), nav.tab( "YOU", route.to_path(Settings), section == Settings, inbox_pending_count(model.inbox), ), ]) } fn notice_view(notice: Option(Notice)) -> Element(Msg) { case notice { Some(Notice(level, message)) -> html.div( [ attr.class("notice notice--" <> level_class(level)), attr.role("alert"), ], [ html.span([attr.class("notice__icon")], [text(level_icon(level))]), html.span([attr.class("notice__msg")], [text(message)]), html.button( [ attr.class("notice__dismiss"), attr.type_("button"), attr.attribute("aria-label", "Dismiss"), event.on_click(ClearNotice), ], [text("✕")], ), ], ) None -> element.none() } } fn level_class(level: NoticeLevel) -> String { case level { Success -> "success" Failure -> "error" Info -> "info" Warning -> "warning" } } fn level_icon(level: NoticeLevel) -> String { case level { Success -> "✓" Failure -> "✕" Info -> "◌" Warning -> "⚠" } }