From c6227a39d4d7e84369ca1277bf82ce09cb78bf3e Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 13 Jul 2026 21:56:06 +0200 Subject: [PATCH] refactor(web): split ui/components into cohesive submodules --- web/src/at_record_web/pages/add.gleam | 45 ++- web/src/at_record_web/pages/browse.gleam | 20 +- web/src/at_record_web/pages/crate.gleam | 38 +- web/src/at_record_web/pages/edit_inbox.gleam | 15 +- web/src/at_record_web/pages/login.gleam | 16 +- web/src/at_record_web/pages/record.gleam | 35 +- web/src/at_record_web/pages/scan.gleam | 40 +- web/src/at_record_web/pages/settings.gleam | 33 +- web/src/at_record_web/ui/app_bar.gleam | 72 ++++ web/src/at_record_web/ui/components.gleam | 386 ------------------- web/src/at_record_web/ui/controls.gleam | 89 +++++ web/src/at_record_web/ui/covers.gleam | 121 ++++++ web/src/at_record_web/ui/forms.gleam | 64 +++ web/src/at_record_web/ui/record_detail.gleam | 57 +++ web/src/at_record_web/view.gleam | 28 +- 15 files changed, 555 insertions(+), 504 deletions(-) create mode 100644 web/src/at_record_web/ui/app_bar.gleam delete mode 100644 web/src/at_record_web/ui/components.gleam create mode 100644 web/src/at_record_web/ui/controls.gleam create mode 100644 web/src/at_record_web/ui/covers.gleam create mode 100644 web/src/at_record_web/ui/forms.gleam create mode 100644 web/src/at_record_web/ui/record_detail.gleam diff --git a/web/src/at_record_web/pages/add.gleam b/web/src/at_record_web/pages/add.gleam index 7ca562e..8cddf98 100644 --- a/web/src/at_record_web/pages/add.gleam +++ b/web/src/at_record_web/pages/add.gleam @@ -10,7 +10,10 @@ import at_record_web/msg.{ FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, FormStatus, FormTitle, FormYear, SubmitAdd, UseArtist, UseDiscogs, } -import at_record_web/ui/components as c +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 @@ -26,17 +29,17 @@ pub fn view(model: Model) -> Element(Msg) { fn add_form_view(model: Model) -> Element(Msg) { let form = model.form - c.card([ - c.section_label("ADD A RECORD"), + cov.card([ + frm.section_label("ADD A RECORD"), title_autocomplete(model), artist_autocomplete(model), html.div([attr.class("field-row")], [ - c.field("FORMAT", "LP", form.format, FormFormat, "text"), - c.field("YEAR", "1991", form.year, FormYear, "number"), + frm.field("FORMAT", "LP", form.format, FormFormat, "text"), + frm.field("YEAR", "1991", form.year, FormYear, "number"), ]), html.div([attr.class("field")], [ - c.section_label("STATUS"), - c.segment( + frm.section_label("STATUS"), + frm.segment( [#("owned", "OWNED"), #("wanted", "WANTED")], form.status, FormStatus, @@ -46,9 +49,9 @@ fn add_form_view(model: Model) -> Element(Msg) { sleeve_grade_select(form), rating_select(form), ]), - c.field("FOLDER", "Rock A-M", form.folder, FormFolder, "text"), + frm.field("FOLDER", "Rock A-M", form.folder, FormFolder, "text"), acquisition_view(form), - c.button(save_label(model.busy), c.Primary, [ + ctl.button(save_label(model.busy), ctl.Primary, [ event.on_click(SubmitAdd), attr.disabled(model.busy), attr.class("btn--block"), @@ -58,7 +61,7 @@ fn add_form_view(model: Model) -> Element(Msg) { fn sleeve_grade_select(form: Form) -> Element(Msg) { let options = - list.map(["", ..c.grades], fn(g) { + list.map(["", ..rd.grades], fn(g) { let label = case g { "" -> "Grade…" _ -> g @@ -88,12 +91,18 @@ fn rating_select(form: Form) -> Element(Msg) { fn acquisition_view(form: Form) -> Element(Msg) { html.div([attr.class("field")], [ - c.section_label("ACQUISITION"), + frm.section_label("ACQUISITION"), html.div([attr.class("field-row")], [ - c.field("PRICE", "40.00", form.price_amount, FormPriceAmount, "text"), - c.field("CURRENCY", "EUR", form.price_currency, FormPriceCurrency, "text"), + frm.field("PRICE", "40.00", form.price_amount, FormPriceAmount, "text"), + frm.field( + "CURRENCY", + "EUR", + form.price_currency, + FormPriceCurrency, + "text", + ), ]), - c.field( + frm.field( "FROM", "Record shop, discogs seller, …", form.counterparty, @@ -121,7 +130,7 @@ fn title_autocomplete(model: Model) -> Element(Msg) { attr.autocomplete("off"), event.on_input(FormTitle), ]), - c.suggestions(model.discogs.results, suggestion_row), + frm.suggestions(model.discogs.results, suggestion_row), ]) } @@ -132,7 +141,7 @@ fn suggestion_row(r: DiscogsResult) -> Element(Msg) { |> string.join(" · ") html.li([], [ html.button([attr.type_("button"), event.on_click(UseDiscogs(r))], [ - c.thumb(r.thumb_url), + cov.thumb(r.thumb_url), html.div([attr.class("suggestion__main")], [ html.strong([], [text(r.title)]), html.div([attr.class("item-meta")], [text(r.artist <> " · " <> meta)]), @@ -154,14 +163,14 @@ fn artist_autocomplete(model: Model) -> Element(Msg) { attr.autocomplete("off"), event.on_input(FormArtist), ]), - c.suggestions(model.discogs.artist_results, artist_suggestion_row), + frm.suggestions(model.discogs.artist_results, artist_suggestion_row), ]) } fn artist_suggestion_row(a: ArtistHit) -> Element(Msg) { html.li([], [ html.button([attr.type_("button"), event.on_click(UseArtist(a))], [ - c.thumb(a.thumb_url), + cov.thumb(a.thumb_url), html.div([attr.class("suggestion__main")], [ html.strong([], [text(a.name)]), ]), diff --git a/web/src/at_record_web/pages/browse.gleam b/web/src/at_record_web/pages/browse.gleam index 98488de..0b3efe1 100644 --- a/web/src/at_record_web/pages/browse.gleam +++ b/web/src/at_record_web/pages/browse.gleam @@ -4,7 +4,9 @@ import at_record_web/model.{type BrowseRelease, type Model} import at_record_web/msg.{type Msg, BrowseAdd, BrowseGenre, BrowseQuery} -import at_record_web/ui/components as c +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 gleam/list import gleam/option.{type Option, None, Some} import lustre/attribute as attr @@ -21,8 +23,8 @@ pub fn view(model: Model) -> Element(Msg) { fn search_bar(query: String, genre: String) -> Element(Msg) { html.div([attr.class("field-row")], [ - c.field("SEARCH", "Search title or artist", query, BrowseQuery, "text"), - c.field("GENRE", "Genre", genre, BrowseGenre, "text"), + frm.field("SEARCH", "Search title or artist", query, BrowseQuery, "text"), + frm.field("GENRE", "Genre", genre, BrowseGenre, "text"), ]) } @@ -43,10 +45,10 @@ fn grid( fn card(row: BrowseRelease, adding: Option(#(String, String))) -> Element(Msg) { let artist = option.unwrap(row.artist_display, "") - let color = c.cover_color(row.title <> artist) + let color = cov.cover_color(row.title <> artist) html.div([attr.class("cover-card browse-card")], [ html.div([attr.class("cover-tile cover-tile--card cover-tile--" <> color)], [ - c.tile_art(option.or(row.cover_url, row.thumb_url), row.title), + cov.tile_art(option.or(row.cover_url, row.thumb_url), row.title), ]), html.div([attr.class("cover-card__info")], [ html.span([attr.class("cover-card__title")], [text(row.title)]), @@ -64,8 +66,8 @@ fn actions( adding: Option(#(String, String)), ) -> Element(Msg) { case row.owned, row.wanted { - True, _ -> c.badge("IN YOUR CRATE", c.Owned) - _, True -> c.badge("WANTED", c.Wanted) + True, _ -> ctl.badge("IN YOUR CRATE", ctl.Owned) + _, True -> ctl.badge("WANTED", ctl.Wanted) False, False -> quick_actions(row, adding) } } @@ -80,11 +82,11 @@ fn quick_actions( _ -> None } html.div([attr.class("browse-card__actions")], [ - c.button(action_label("WANT IT", clicked, "wanted"), c.Ghost, [ + ctl.button(action_label("WANT IT", clicked, "wanted"), ctl.Ghost, [ event.on_click(BrowseAdd(row.uri, row.cid, "wanted")), attr.disabled(busy), ]), - c.button(action_label("I HAVE THIS", clicked, "owned"), c.Primary, [ + ctl.button(action_label("I HAVE THIS", clicked, "owned"), ctl.Primary, [ event.on_click(BrowseAdd(row.uri, row.cid, "owned")), attr.disabled(busy), ]), diff --git a/web/src/at_record_web/pages/crate.gleam b/web/src/at_record_web/pages/crate.gleam index 4d65149..1e38e4b 100644 --- a/web/src/at_record_web/pages/crate.gleam +++ b/web/src/at_record_web/pages/crate.gleam @@ -12,7 +12,9 @@ import at_record_web/msg.{ type Msg, RetryShelf, SetDisplay, SetView, ShowMoreCrate, } import at_record_web/route -import at_record_web/ui/components as c +import at_record_web/ui/app_bar as bar +import at_record_web/ui/controls as ctl +import at_record_web/ui/covers as cov import gleam/int import gleam/list import gleam/option @@ -44,7 +46,7 @@ fn loaded_state( let total = list.length(items) let visible = list.take(items, window) html.div([attr.class("list-screen")], [ - hero(hero_count(view, items), c.badge("◆ GROWING", c.Accent)), + hero(hero_count(view, items), ctl.badge("◆ GROWING", ctl.Accent)), html.div([attr.class("body list-body")], [ toolbar(view, display_toggle(display)), html.div([attr.class("list-scroll")], [ @@ -52,7 +54,7 @@ fn loaded_state( load_more(list.length(visible), total), ]), ]), - c.fab("ADD", route.to_path(Scan)), + bar.fab("ADD", route.to_path(Scan)), ]) } @@ -68,7 +70,7 @@ fn load_more(shown: Int, total: Int) -> Element(Msg) { "showing " <> int.to_string(shown) <> " of " <> int.to_string(total), ), ]), - c.button("LOAD MORE", c.Ghost, [ + ctl.button("LOAD MORE", ctl.Ghost, [ attr.class("btn--block"), event.on_click(ShowMoreCrate), ]), @@ -127,9 +129,9 @@ fn hero_count(view: String, items: List(Entry)) -> String { fn toolbar(view: String, right: Element(Msg)) -> Element(Msg) { html.div([attr.class("toolbar")], [ html.div([attr.class("tags")], [ - filter("owned", "OWNED", c.Owned, view), - filter("wanted", "WANTED", c.Wanted, view), - filter("history", "HISTORY", c.Ink, view), + filter("owned", "OWNED", ctl.Owned, view), + filter("wanted", "WANTED", ctl.Wanted, view), + filter("history", "HISTORY", ctl.Ink, view), ]), right, ]) @@ -138,10 +140,10 @@ fn toolbar(view: String, right: Element(Msg)) -> Element(Msg) { fn filter( value: String, label: String, - variant: c.Variant, + variant: ctl.Variant, current: String, ) -> Element(Msg) { - c.chip_button(label, variant, value == current, SetView(value)) + ctl.chip_button(label, variant, value == current, SetView(value)) } fn grid(items: List(Entry)) -> Element(Msg) { @@ -150,7 +152,7 @@ fn grid(items: List(Entry)) -> Element(Msg) { fn card(entry: Entry) -> Element(Msg) { let snap = entry.snapshot - c.cover_card( + cov.cover_card( route.to_path(Record(entry.entry_id)), snap.title <> snap.artist_display, entry.status, @@ -167,7 +169,7 @@ fn rows(items: List(Entry)) -> Element(Msg) { fn row(entry: Entry) -> Element(Msg) { let snap = entry.snapshot - c.cover_row( + cov.cover_row( route.to_path(Record(entry.entry_id)), snap.title <> snap.artist_display, entry.status, @@ -194,7 +196,7 @@ fn format_line(snap: Snapshot) -> String { fn loading_state(view: String, display: Display) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero("…fetching your records", c.badge("◆ LOADING", c.Accent)), + hero("…fetching your records", ctl.badge("◆ LOADING", ctl.Accent)), html.div([attr.class("body list-body")], [ // Filter chips + view toggle are navigation, not content: keep them // stable across loading so the controls do not flicker out and back. @@ -211,7 +213,7 @@ fn loading_state(view: String, display: Display) -> Element(Msg) { ]), ]), ]), - c.fab("ADD", route.to_path(Scan)), + bar.fab("ADD", route.to_path(Scan)), ]) } @@ -255,20 +257,20 @@ fn empty_state(view: String) -> Element(Msg) { fn empty_filter_state(view: String, hint: String) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero(hero_count(view, []), c.badge("◆ GROWING", c.Accent)), + hero(hero_count(view, []), ctl.badge("◆ GROWING", ctl.Accent)), html.div([attr.class("body list-body")], [ toolbar(view, element.none()), html.div([attr.class("list-scroll")], [ html.p([attr.class("empty")], [text(hint)]), ]), ]), - c.fab("ADD", route.to_path(Scan)), + bar.fab("ADD", route.to_path(Scan)), ]) } fn empty_crate_state(view: String) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero("0 in your crate", c.badge("◆ FRESH", c.Accent)), + hero("0 in your crate", ctl.badge("◆ FRESH", ctl.Accent)), html.div([attr.class("body list-body")], [ toolbar(view, element.none()), html.div([attr.class("list-scroll")], [ @@ -344,7 +346,7 @@ fn vinyl_glyph() -> Element(Msg) { fn failed_state() -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero("couldn't count your records", c.badge("✕ OFFLINE", c.Alert)), + hero("couldn't count your records", ctl.badge("✕ OFFLINE", ctl.Alert)), html.div([attr.class("body list-body")], [ html.div([attr.class("list-scroll")], [ html.div([attr.class("error-state")], [ @@ -372,6 +374,6 @@ fn failed_state() -> Element(Msg) { ]), ]), ]), - c.fab("ADD", route.to_path(Scan)), + bar.fab("ADD", route.to_path(Scan)), ]) } diff --git a/web/src/at_record_web/pages/edit_inbox.gleam b/web/src/at_record_web/pages/edit_inbox.gleam index 39b52b1..a8b33bb 100644 --- a/web/src/at_record_web/pages/edit_inbox.gleam +++ b/web/src/at_record_web/pages/edit_inbox.gleam @@ -10,7 +10,8 @@ import at_record_web/model.{ ProposalReviewing, } import at_record_web/msg.{type Msg, ApplyProposal, IgnoreProposal, OnRouteChange} -import at_record_web/ui/components as c +import at_record_web/ui/controls as ctl +import at_record_web/ui/covers as cov import gleam/int import gleam/list import gleam/option.{type Option, None, Some} @@ -28,9 +29,11 @@ fn inbox_view(inbox: Inbox) -> Element(Msg) { case inbox { InboxLoading -> element.none() InboxFailed -> - c.card([ + cov.card([ html.p([attr.class("hint")], [text("Couldn't load your edit inbox.")]), - c.button("RETRY", c.Ghost, [event.on_click(OnRouteChange(EditInbox))]), + ctl.button("RETRY", ctl.Ghost, [ + event.on_click(OnRouteChange(EditInbox)), + ]), ]) InboxLoaded([]) -> html.p([attr.class("hint")], [ @@ -69,7 +72,7 @@ fn card(item: InboxCard) -> Element(Msg) { fn proposal_card(item: InboxCard) -> Element(Msg) { let p = item.proposal let applying = item.status == ProposalApplying - c.card([ + cov.card([ html.div([attr.class("proposal-card__header")], [ html.span([attr.class("proposal-card__title")], [text(p.release_title)]), html.span([attr.class("proposal-card__by")], [ @@ -161,11 +164,11 @@ fn display_list(value: Option(List(String))) -> String { fn actions(uri: String, cid: String, applying: Bool) -> Element(Msg) { html.div([attr.class("proposal-actions")], [ - c.button(apply_label(applying), c.Primary, [ + ctl.button(apply_label(applying), ctl.Primary, [ event.on_click(ApplyProposal(uri, cid)), attr.disabled(applying), ]), - c.button("IGNORE", c.Ghost, [ + ctl.button("IGNORE", ctl.Ghost, [ event.on_click(IgnoreProposal(uri)), attr.disabled(applying), ]), diff --git a/web/src/at_record_web/pages/login.gleam b/web/src/at_record_web/pages/login.gleam index 1704eec..a03c819 100644 --- a/web/src/at_record_web/pages/login.gleam +++ b/web/src/at_record_web/pages/login.gleam @@ -5,7 +5,9 @@ import at_record_web/model.{type HandleSuggestion, type Model} import at_record_web/msg.{ type Msg, HandleChanged, StartLogin, UseHandleSuggestion, } -import at_record_web/ui/components as c +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 gleam/list import gleam/option import gleam/string @@ -25,7 +27,7 @@ pub fn view(model: Model) -> Element(Msg) { html.h2([], [text("AT·RECORD")]), html.p([attr.class("tagline")], [text("your crate, stored on your PDS")]), error_panel(model.login_error), - c.card([ + cov.card([ handle_autocomplete(model), login_action(model.login_handle), html.p([attr.class("hint")], [ @@ -40,8 +42,8 @@ pub fn view(model: Model) -> Element(Msg) { /// without one at PDS providers they can sign up on. Step one of the /// registration roadmap item; hosting our own PDS comes later. fn register_section() -> Element(Msg) { - c.card([ - c.section_label("NEW TO THE ATMOSPHERE?"), + cov.card([ + frm.section_label("NEW TO THE ATMOSPHERE?"), html.p([attr.class("hint")], [ text("Create an account on a PDS provider, then come back and log in:"), ]), @@ -124,14 +126,14 @@ fn handle_autocomplete(model: Model) -> Element(Msg) { attr.autocomplete("off"), event.on_input(HandleChanged), ]), - c.suggestions(model.login_suggestions, suggestion_row), + frm.suggestions(model.login_suggestions, suggestion_row), ]) } fn suggestion_row(s: HandleSuggestion) -> Element(Msg) { html.li([], [ html.button([attr.type_("button"), event.on_click(UseHandleSuggestion(s))], [ - c.thumb(s.avatar), + cov.thumb(s.avatar), html.div([attr.class("suggestion__main")], [ html.strong([], [text("@" <> s.handle)]), case s.display_name { @@ -147,7 +149,7 @@ fn login_action(handle: String) -> Element(Msg) { // A button (not an ): StartLogin runs a full-page navigation via FFI. An // anchor would be hijacked by modem (same-origin) and never reach the server. let disabled = string.trim(handle) == "" - c.button("LOG IN WITH ATPROTO", c.Primary, [ + ctl.button("LOG IN WITH ATPROTO", ctl.Primary, [ attr.disabled(disabled), attr.class("btn--block"), event.on_click(StartLogin), diff --git a/web/src/at_record_web/pages/record.gleam b/web/src/at_record_web/pages/record.gleam index 154e7c2..93e4ee1 100644 --- a/web/src/at_record_web/pages/record.gleam +++ b/web/src/at_record_web/pages/record.gleam @@ -11,7 +11,10 @@ import at_record_web/msg.{ SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, } import at_record_web/provenance -import at_record_web/ui/components as c +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/dict import gleam/int import gleam/list @@ -25,7 +28,7 @@ import lustre/event pub fn view(model: Model, entry: Entry) -> Element(Msg) { let snap = entry.snapshot html.div([attr.class("page-scroll")], [ - c.detail_cover( + cov.detail_cover( snap.title <> snap.artist_display, snap.title, entry.status, @@ -60,7 +63,7 @@ fn titleblock(model: Model, entry: Entry) -> Element(Msg) { via_handle(model, entry.entry_id), provenance_line(entry), case entry.rating { - Some(value) -> c.stars(value) + Some(value) -> rd.stars(value) None -> element.none() }, ]) @@ -87,11 +90,11 @@ fn provenance_line(entry: Entry) -> Element(Msg) { fn condition(entry: Entry) -> Element(Msg) { let grade = option.unwrap(entry.media_grade, "—") html.div([], [ - c.section_label("CONDITION"), + frm.section_label("CONDITION"), html.div( [attr.class("grades")], [ - Some(c.grade_card("MEDIA", grade, c.grade_name(grade))), + Some(rd.grade_card("MEDIA", grade, rd.grade_name(grade))), sleeve_card(entry), ] |> option.values, @@ -101,7 +104,7 @@ fn condition(entry: Entry) -> Element(Msg) { fn sleeve_card(entry: Entry) -> option.Option(Element(Msg)) { option.map(entry.sleeve_grade, fn(grade) { - c.grade_card("SLEEVE", grade, c.grade_name(grade)) + rd.grade_card("SLEEVE", grade, rd.grade_name(grade)) }) } @@ -132,7 +135,7 @@ fn detail_rows( _ -> html.div( [attr.class("detail-rows")], - list.map(rows, fn(row) { c.detail_row(row.0, row.1) }), + list.map(rows, fn(row) { rd.detail_row(row.0, row.1) }), ) } } @@ -149,14 +152,14 @@ fn release_chips(release_info: Option(ReleaseInfo)) -> Element(Msg) { _ -> html.div( [attr.class("tags")], - list.map(tags, fn(tag) { c.chip(tag, c.Surface) }), + list.map(tags, fn(tag) { ctl.chip(tag, ctl.Surface) }), ) } } fn notes(notes: Option(String)) -> Element(Msg) { case notes { - Some(body) -> c.notes(body) + Some(body) -> rd.notes(body) None -> element.none() } } @@ -175,9 +178,9 @@ fn actions( False -> #("REMOVE", ArmRemove) } html.div([attr.class("actions")], [ - c.button(edit_label, c.Primary, [event.on_click(ToggleEdit)]), - c.button("AMEND", c.Ghost, [event.on_click(ToggleAmend)]), - c.button(remove_label, c.Danger, [event.on_click(remove_msg)]), + ctl.button(edit_label, ctl.Primary, [event.on_click(ToggleEdit)]), + ctl.button("AMEND", ctl.Ghost, [event.on_click(ToggleAmend)]), + ctl.button(remove_label, ctl.Danger, [event.on_click(remove_msg)]), ]) } @@ -189,7 +192,7 @@ fn amend_panel(model: Model) -> Element(Msg) { True -> { let d = model.amend html.div([attr.class("amend-panel")], [ - c.section_label("AMEND CATALOG INFO"), + frm.section_label("AMEND CATALOG INFO"), amend_field("TITLE", "title", d.title), html.div([attr.class("field-row")], [ amend_field("RELEASED", "released", d.released), @@ -197,7 +200,7 @@ fn amend_panel(model: Model) -> Element(Msg) { ]), amend_field("GENRES (COMMA SEPARATED)", "genres", d.genres), amend_field("STYLES (COMMA SEPARATED)", "styles", d.styles), - c.section_label("COVER ART"), + frm.section_label("COVER ART"), html.label([attr.class("field checkbox")], [ html.input([ attr.type_("checkbox"), @@ -215,7 +218,7 @@ fn amend_panel(model: Model) -> Element(Msg) { event.on_change(fn(_) { CoverFileChosen }), ]), ]), - c.button(publish_label(model.publishing), c.Dark, [ + ctl.button(publish_label(model.publishing), ctl.Dark, [ event.on_click(SubmitAmend), attr.disabled(model.busy), attr.class("btn--block"), @@ -257,7 +260,7 @@ fn edit_panel(entry: Entry, editing: Bool, busy: Bool) -> Element(Msg) { fn grade_select(entry: Entry, busy: Bool) -> Element(Msg) { let current = option.unwrap(entry.media_grade, "") let options = - list.map(["", ..c.grades], fn(g) { + list.map(["", ..rd.grades], fn(g) { let label = case g { "" -> "Grade…" _ -> g diff --git a/web/src/at_record_web/pages/scan.gleam b/web/src/at_record_web/pages/scan.gleam index 5edc9ab..5cdd2cc 100644 --- a/web/src/at_record_web/pages/scan.gleam +++ b/web/src/at_record_web/pages/scan.gleam @@ -9,7 +9,9 @@ import at_record_web/model.{ NetworkMatched, NoMatch, Pending, } import at_record_web/msg.{type Msg, AddScannedItem, AddSuggestion, RemoveScanRow} -import at_record_web/ui/components as c +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 gleam/int import gleam/list import gleam/option.{None, Some} @@ -29,8 +31,8 @@ pub fn view(model: Model) -> Element(Msg) { fn camera_view(model: Model) -> Element(Msg) { case model.scan.camera_error { Some(reason) -> - c.card([ - c.section_label("CAMERA"), + cov.card([ + frm.section_label("CAMERA"), html.p([attr.class("item-meta")], [text(reason)]), html.p([attr.class("item-meta")], [ text("Use the Add page to search Discogs manually instead."), @@ -57,8 +59,8 @@ fn rows_view(rows: List(ScanRow), busy: Bool) -> Element(Msg) { case rows { [] -> element.none() _ -> - c.card([ - c.section_label("SCANNED"), + cov.card([ + frm.section_label("SCANNED"), html.ul([attr.class("scan-rows")], list.map(rows, row_view(_, busy))), ]) } @@ -88,8 +90,8 @@ fn outcome_view( html.strong([], [text(barcode)]), html.div([attr.class("item-meta")], [text("no Discogs match")]), ]), - c.link_button("SEARCH", c.Ghost, "/add"), - c.button("DISMISS", c.Ghost, [ + ctl.link_button("SEARCH", ctl.Ghost, "/add"), + ctl.button("DISMISS", ctl.Ghost, [ event.on_click(RemoveScanRow(barcode)), ]), ]), @@ -107,7 +109,7 @@ fn outcome_view( |> option.values |> string.join(" · ") html.div([attr.class("scan-row__body")], [ - c.thumb(result.thumb_url), + cov.thumb(result.thumb_url), html.div([attr.class("scan-row__main")], [ html.strong([], [text(result.title)]), html.div([attr.class("item-meta")], [ @@ -121,14 +123,14 @@ fn outcome_view( ]), ]), case owned { - True -> c.badge("ALREADY OWNED", c.Owned) + True -> ctl.badge("ALREADY OWNED", ctl.Owned) False -> - c.button("ADD", c.Primary, [ + ctl.button("ADD", ctl.Primary, [ event.on_click(AddScannedItem(barcode)), attr.disabled(busy), ]) }, - c.button("×", c.Ghost, [ + ctl.button("×", ctl.Ghost, [ attr.class("scan-row__dismiss"), event.on_click(RemoveScanRow(barcode)), ]), @@ -144,7 +146,7 @@ fn adding_view(item: ScanItem) -> Element(Msg) { FromNetwork(release) -> #(release.thumb_url, release.title) } html.div([attr.class("scan-row__body")], [ - c.thumb(thumb_url), + cov.thumb(thumb_url), html.div([attr.class("scan-row__main")], [ html.strong([], [text(title)]), html.div([attr.class("item-meta")], [text("adding…")]), @@ -162,17 +164,17 @@ fn network_view( ) -> Element(Msg) { let artist = option.unwrap(release.artist_display, "") html.div([attr.class("scan-row__body")], [ - c.thumb(release.thumb_url), + cov.thumb(release.thumb_url), html.div([attr.class("scan-row__main")], [ html.strong([], [text(release.title)]), html.div([attr.class("item-meta")], [text(artist)]), via_handle(release.publisher_handle), ]), - c.button("ADD", c.Primary, [ + ctl.button("ADD", ctl.Primary, [ event.on_click(AddScannedItem(barcode)), attr.disabled(busy), ]), - c.button("×", c.Ghost, [ + ctl.button("×", ctl.Ghost, [ attr.class("scan-row__dismiss"), event.on_click(RemoveScanRow(barcode)), ]), @@ -197,7 +199,7 @@ fn suggestions_view( [] -> element.none() _ -> html.div([attr.class("scan-suggestions")], [ - c.section_label("DID YOU MEAN?"), + frm.section_label("DID YOU MEAN?"), ..list.map(list.take(suggestions, 2), suggestion_view(barcode, _, busy)) ]) } @@ -214,15 +216,15 @@ fn suggestion_view( |> option.values |> string.join(" · ") html.div([attr.class("scan-row__body")], [ - c.thumb(result.thumb_url), + cov.thumb(result.thumb_url), html.div([attr.class("scan-row__main")], [ html.strong([], [text(result.title)]), html.div([attr.class("item-meta")], [ text(result.artist <> " · " <> meta), ]), ]), - c.chip(int.to_string(suggestion.confidence) <> "%", c.Surface), - c.button("ADD", c.Primary, [ + ctl.chip(int.to_string(suggestion.confidence) <> "%", ctl.Surface), + ctl.button("ADD", ctl.Primary, [ event.on_click(AddSuggestion(barcode, result)), attr.disabled(busy), ]), diff --git a/web/src/at_record_web/pages/settings.gleam b/web/src/at_record_web/pages/settings.gleam index 5baf706..aa33cee 100644 --- a/web/src/at_record_web/pages/settings.gleam +++ b/web/src/at_record_web/pages/settings.gleam @@ -8,7 +8,10 @@ import at_record_web/msg.{ DiscogsImportWantlist, Logout, } import at_record_web/route -import at_record_web/ui/components as c +import at_record_web/ui/app_bar as bar +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 gleam/option import lustre/attribute as attr import lustre/element.{type Element, text} @@ -20,7 +23,7 @@ pub fn view(model: Model) -> Element(Msg) { account_header(model), discogs_account_view(model), inbox_row(model), - c.button("LOG OUT", c.Danger, [ + ctl.button("LOG OUT", ctl.Danger, [ attr.class("btn--block"), event.on_click(Logout), ]), @@ -31,7 +34,7 @@ fn account_header(model: Model) -> Element(Msg) { let handle = current_handle(model) html.div([attr.class("settings-account")], [ html.span([attr.class("avatar avatar--lg")], [ - c.avatar_content(model.avatar, handle), + bar.avatar_content(model.avatar, handle), ]), html.div([attr.class("settings-account__text")], [ html.p([attr.class("settings-account__handle")], [text("@" <> handle)]), @@ -50,8 +53,8 @@ fn current_handle(model: Model) -> String { } fn inbox_row(model: Model) -> Element(Msg) { - c.card([ - c.popout_link( + cov.card([ + bar.popout_link( "EDIT INBOX", route.to_path(EditInbox), model.inbox_pending_count(model.inbox), @@ -60,44 +63,48 @@ fn inbox_row(model: Model) -> Element(Msg) { } fn discogs_account_view(model: Model) -> Element(Msg) { - c.card([ - c.section_label("DISCOGS ACCOUNT"), + cov.card([ + frm.section_label("DISCOGS ACCOUNT"), case model.discogs.username { option.None -> html.div([attr.class("connect-row")], [ html.span([attr.class("item-meta")], [ text("Connect your Discogs account to import your collection."), ]), - c.button("CONNECT DISCOGS", c.Ghost, [event.on_click(DiscogsConnect)]), + ctl.button("CONNECT DISCOGS", ctl.Ghost, [ + event.on_click(DiscogsConnect), + ]), ]) option.Some(username) -> html.div([attr.class("connect-row")], [ html.span([attr.class("item-meta")], [ text("Connected as " <> username), ]), - c.button( + ctl.button( case model.discogs.importing { True -> "IMPORTING…" False -> "IMPORT COLLECTION" }, - c.Dark, + ctl.Dark, [ attr.disabled(model.discogs.importing), event.on_click(DiscogsImport), ], ), - c.button( + ctl.button( case model.discogs.importing { True -> "IMPORTING…" False -> "IMPORT WANTLIST" }, - c.Ghost, + ctl.Ghost, [ attr.disabled(model.discogs.importing), event.on_click(DiscogsImportWantlist), ], ), - c.button("DISCONNECT", c.Ghost, [event.on_click(DiscogsDisconnect)]), + ctl.button("DISCONNECT", ctl.Ghost, [ + event.on_click(DiscogsDisconnect), + ]), ]) }, ]) diff --git a/web/src/at_record_web/ui/app_bar.gleam b/web/src/at_record_web/ui/app_bar.gleam new file mode 100644 index 0000000..4fa5ffc --- /dev/null +++ b/web/src/at_record_web/ui/app_bar.gleam @@ -0,0 +1,72 @@ +//// App bar chrome: nav bar, brand/title text, icon buttons, avatar, popout +//// links, and the floating action button. + +import at_record_web/ui/controls.{Accent} +import at_record_web/ui/covers +import gleam/int +import gleam/option.{type Option, None, Some} +import gleam/string +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +pub fn app_bar( + left: Element(msg), + middle: Element(msg), + right: Element(msg), +) -> Element(msg) { + html.div([attr.class("app-bar")], [left, middle, right]) +} + +pub fn brand(label: String) -> Element(msg) { + html.span([attr.class("app-bar__title")], [text(label)]) +} + +pub fn bar_title(label: String) -> Element(msg) { + html.span([attr.class("app-bar__title--sub")], [text(label)]) +} + +pub fn icon_link(glyph: String, href: String) -> Element(msg) { + html.a([attr.class("icon-btn"), attr.href(href)], [text(glyph)]) +} + +pub fn icon_button(glyph: String, on_click: msg) -> Element(msg) { + html.button([attr.class("icon-btn"), event.on_click(on_click)], [text(glyph)]) +} + +pub fn icon_disabled(glyph: String) -> Element(msg) { + html.button([attr.class("icon-btn"), attr.disabled(True)], [text(glyph)]) +} + +pub fn avatar(initial: String) -> Element(msg) { + html.span([attr.class("avatar")], [text(string.uppercase(initial))]) +} + +/// The avatar's inner content: the real avatar image over the letter-tile +/// fallback (the uppercase initial of `seed`). +pub fn avatar_content(avatar: Option(String), seed: String) -> Element(msg) { + case avatar { + Some(url) -> html.img([attr.src(url), attr.alt("avatar")]) + None -> text(covers.cover_initial(seed)) + } +} + +/// A full-width popout row that navigates on click, with an accent count +/// badge when `count` is positive (0 renders no badge at all). +pub fn popout_link(label: String, href: String, count: Int) -> Element(msg) { + html.a([attr.class("popout__link"), attr.href(href)], [ + text(label), + case count > 0 { + True -> controls.badge(int.to_string(count), Accent) + False -> element.none() + }, + ]) +} + +pub fn fab(label: String, href: String) -> Element(msg) { + html.a([attr.class("fab"), attr.href(href)], [ + html.span([attr.class("fab__plus")], [text("+")]), + html.span([attr.class("fab__label")], [text(label)]), + ]) +} diff --git a/web/src/at_record_web/ui/components.gleam b/web/src/at_record_web/ui/components.gleam deleted file mode 100644 index 05e9f43..0000000 --- a/web/src/at_record_web/ui/components.gleam +++ /dev/null @@ -1,386 +0,0 @@ -//// The AT·RECORD design-system layer: small, message-generic view primitives -//// the pages compose, mirroring the Figma neo-brutalist/analog tokens (2px ink -//// borders, hard offset shadows, Anton/Fraunces/Space Mono type). - -import gleam/int -import gleam/list -import gleam/option.{type Option, None, Some} -import gleam/string -import lustre/attribute as attr -import lustre/element.{type Element, text} -import lustre/element/html -import lustre/event - -// --- colour / status variants ---------------------------------------------- - -pub type Variant { - Ink - Owned - Wanted - Accent - Surface - Alert -} - -fn variant_suffix(variant: Variant) -> String { - case variant { - Ink -> "ink" - Owned -> "owned" - Wanted -> "wanted" - Accent -> "accent" - Surface -> "surface" - Alert -> "alert" - } -} - -pub type Btn { - Primary - Danger - Dark - Ghost -} - -fn btn_class(variant: Btn) -> String { - case variant { - Primary -> "btn btn--primary" - Danger -> "btn btn--danger" - Dark -> "btn btn--dark" - Ghost -> "btn btn--ghost" - } -} - -// --- chrome ----------------------------------------------------------------- - -pub fn app_bar( - left: Element(msg), - middle: Element(msg), - right: Element(msg), -) -> Element(msg) { - html.div([attr.class("app-bar")], [left, middle, right]) -} - -pub fn brand(label: String) -> Element(msg) { - html.span([attr.class("app-bar__title")], [text(label)]) -} - -pub fn bar_title(label: String) -> Element(msg) { - html.span([attr.class("app-bar__title--sub")], [text(label)]) -} - -pub fn icon_link(glyph: String, href: String) -> Element(msg) { - html.a([attr.class("icon-btn"), attr.href(href)], [text(glyph)]) -} - -pub fn icon_button(glyph: String, on_click: msg) -> Element(msg) { - html.button([attr.class("icon-btn"), event.on_click(on_click)], [text(glyph)]) -} - -pub fn icon_disabled(glyph: String) -> Element(msg) { - html.button([attr.class("icon-btn"), attr.disabled(True)], [text(glyph)]) -} - -pub fn avatar(initial: String) -> Element(msg) { - html.span([attr.class("avatar")], [text(string.uppercase(initial))]) -} - -/// The avatar's inner content: the real avatar image over the letter-tile -/// fallback (the uppercase initial of `seed`). -pub fn avatar_content(avatar: Option(String), seed: String) -> Element(msg) { - case avatar { - Some(url) -> html.img([attr.src(url), attr.alt("avatar")]) - None -> text(cover_initial(seed)) - } -} - -// --- chips / badges --------------------------------------------------------- - -pub fn chip(label: String, variant: Variant) -> Element(msg) { - html.span([attr.class("chip chip--" <> variant_suffix(variant))], [ - text(label), - ]) -} - -/// A clickable filter chip; renders muted when inactive. -pub fn chip_button( - label: String, - variant: Variant, - active: Bool, - on_click: msg, -) -> Element(msg) { - let class = case active { - True -> "chip chip--" <> variant_suffix(variant) - False -> "chip chip--off" - } - html.button([attr.class(class), event.on_click(on_click)], [text(label)]) -} - -pub fn badge(label: String, variant: Variant) -> Element(msg) { - html.span([attr.class("badge badge--" <> variant_suffix(variant))], [ - text(label), - ]) -} - -/// OWNED/WANTED pill derived from an entry status string. -pub fn status_badge(status: String) -> Element(msg) { - case status { - "wanted" -> badge("WANTED", Wanted) - _ -> badge("OWNED", Owned) - } -} - -// --- containers / buttons --------------------------------------------------- - -pub fn card(children: List(Element(msg))) -> Element(msg) { - html.section([attr.class("card")], children) -} - -pub fn button( - label: String, - variant: Btn, - attrs: List(attr.Attribute(msg)), -) -> Element(msg) { - html.button([attr.class(btn_class(variant)), ..attrs], [text(label)]) -} - -pub fn link_button(label: String, variant: Btn, href: String) -> Element(msg) { - html.a([attr.class(btn_class(variant)), attr.href(href)], [text(label)]) -} - -/// A full-width popout row that navigates on click, with an accent count -/// badge when `count` is positive (0 renders no badge at all). -pub fn popout_link(label: String, href: String, count: Int) -> Element(msg) { - html.a([attr.class("popout__link"), attr.href(href)], [ - text(label), - case count > 0 { - True -> badge(int.to_string(count), Accent) - False -> element.none() - }, - ]) -} - -// --- form controls ---------------------------------------------------------- - -pub fn field( - label: String, - placeholder: String, - value: String, - on_input: fn(String) -> msg, - input_type: String, -) -> Element(msg) { - html.label([attr.class("field")], [ - html.span([], [text(label)]), - html.input([ - attr.type_(input_type), - attr.placeholder(placeholder), - attr.value(value), - event.on_input(on_input), - ]), - ]) -} - -/// A dropdown list under a debounced text field (title/handle autocomplete): -/// empty while there's nothing to suggest, else the first 6 rows. -pub fn suggestions(items: List(a), row: fn(a) -> Element(msg)) -> Element(msg) { - case items { - [] -> element.none() - items -> - html.ul( - [attr.class("suggestions dropdown-panel")], - items |> list.take(6) |> list.map(row), - ) - } -} - -pub fn section_label(label: String) -> Element(msg) { - html.p([attr.class("section-label")], [text(label)]) -} - -/// A two-up (or n-up) toggle; `options` are `#(value, label)` pairs. -pub fn segment( - options: List(#(String, String)), - selected: String, - on_select: fn(String) -> msg, -) -> Element(msg) { - html.div( - [attr.class("segment")], - list.map(options, fn(option) { - let #(value, label) = option - let class = case value == selected { - True -> "is-active" - False -> "" - } - html.button([attr.class(class), event.on_click(on_select(value))], [ - text(label), - ]) - }), - ) -} - -// --- cover art -------------------------------------------------------------- - -const tile_colors = ["owned", "wanted", "accent", "sage"] - -/// A cover image over the letter-tile fallback (stable colour hashed from title+artist). -pub fn tile_art(thumb: Option(String), title: String) -> Element(msg) { - case thumb { - Some(src) -> - element.fragment([ - text(cover_initial(title)), - html.img([ - attr.class("cover-tile__img"), - attr.src(src), - attr.alt(title <> " cover"), - ]), - ]) - None -> text(cover_initial(title)) - } -} - -pub fn cover_color(seed: String) -> String { - let sum = - string.to_utf_codepoints(seed) - |> list.fold(0, fn(acc, cp) { acc + string.utf_codepoint_to_int(cp) }) - case list.drop(tile_colors, sum % 4) { - [color, ..] -> color - [] -> "owned" - } -} - -pub fn cover_initial(title: String) -> String { - case string.first(string.trim(title)) { - Ok(char) -> string.uppercase(char) - Error(_) -> "?" - } -} - -/// Grid card: colour tile with a status badge, plus the title/artist/format. -pub fn cover_card( - href: String, - seed: String, - status: String, - title: String, - artist: String, - fmt: String, - thumb: Option(String), -) -> Element(msg) { - let color = cover_color(seed) - html.a([attr.class("cover-card"), attr.href(href)], [ - html.div([attr.class("cover-tile cover-tile--card cover-tile--" <> color)], [ - tile_art(thumb, title), - html.span([attr.class("cover-tile__badge")], [status_badge(status)]), - ]), - html.div([attr.class("cover-card__info")], [ - html.span([attr.class("cover-card__title")], [text(title)]), - html.span([attr.class("cover-card__artist")], [text(artist)]), - html.span([attr.class("cover-card__fmt")], [text(fmt)]), - ]), - ]) -} - -/// List row: a 48px cover thumb beside the title/meta, with the status -/// sticker right-aligned; the list display's sibling to `cover_card`. -pub fn cover_row( - href: String, - seed: String, - status: String, - title: String, - meta: String, - thumb: Option(String), -) -> Element(msg) { - let color = cover_color(seed) - html.a([attr.class("row"), attr.href(href)], [ - html.div([attr.class("cover-tile cover-tile--row cover-tile--" <> color)], [ - tile_art(thumb, title), - ]), - html.div([attr.class("row__info")], [ - html.span([attr.class("row__title")], [text(title)]), - html.span([attr.class("row__meta")], [text(meta)]), - ]), - html.span([attr.class("row__status")], [status_badge(status)]), - ]) -} - -/// Full-bleed detail cover band with status sticker and a format sticker. -pub fn detail_cover( - seed: String, - title: String, - status: String, - fmt: String, - thumb: Option(String), -) -> Element(msg) { - let color = cover_color(seed) - html.div([attr.class("cover-tile cover-tile--detail cover-tile--" <> color)], [ - tile_art(thumb, title), - html.span([attr.class("cover-tile__status")], [status_badge(status)]), - html.span([attr.class("cover-tile__fmt")], [text(fmt)]), - ]) -} - -/// Small thumbnail for list rows; falls back to an empty block. -pub fn thumb(url: Option(String)) -> Element(msg) { - case url { - Some(src) -> - html.img([attr.class("cover"), attr.src(src), attr.alt("cover")]) - None -> html.div([attr.class("cover cover-empty")], []) - } -} - -// --- record detail bits ----------------------------------------------------- - -pub fn stars(rating: Int) -> Element(msg) { - let filled = int.clamp(rating, 0, 5) - let glyphs = string.repeat("★", filled) <> string.repeat("☆", 5 - filled) - html.div([attr.class("stars")], [ - html.span([attr.class("stars__glyphs")], [text(glyphs)]), - html.span([attr.class("stars__count")], [ - text(int.to_string(filled) <> " / 5"), - ]), - ]) -} - -pub fn grade_card(label: String, grade: String, name: String) -> Element(msg) { - html.div([attr.class("grade")], [ - html.span([attr.class("grade__label")], [text(label)]), - html.span([attr.class("grade__value")], [text(grade)]), - html.span([attr.class("grade__name")], [text(name)]), - ]) -} - -pub fn detail_row(label: String, value: String) -> Element(msg) { - html.div([attr.class("detail-row")], [ - html.span([attr.class("detail-row__label")], [text(label)]), - html.span([attr.class("detail-row__value")], [text(value)]), - ]) -} - -pub fn notes(body: String) -> Element(msg) { - html.div([attr.class("notes")], [ - html.p([attr.class("notes__label")], [text("NOTES")]), - html.p([attr.class("notes__text")], [text(body)]), - ]) -} - -pub fn fab(label: String, href: String) -> Element(msg) { - html.a([attr.class("fab"), attr.href(href)], [ - html.span([attr.class("fab__plus")], [text("+")]), - html.span([attr.class("fab__label")], [text(label)]), - ]) -} - -// --- record-grade vocabulary (shared by crate + record) --------------------- - -pub const grades = ["M", "NM", "VG+", "VG", "G+", "G", "F", "P"] - -pub fn grade_name(grade: String) -> String { - case grade { - "M" -> "Mint" - "NM" -> "Near Mint" - "VG+" -> "Very Good Plus" - "VG" -> "Very Good" - "G+" -> "Good Plus" - "G" -> "Good" - "F" -> "Fair" - "P" -> "Poor" - _ -> "—" - } -} diff --git a/web/src/at_record_web/ui/controls.gleam b/web/src/at_record_web/ui/controls.gleam new file mode 100644 index 0000000..e65a63b --- /dev/null +++ b/web/src/at_record_web/ui/controls.gleam @@ -0,0 +1,89 @@ +//// Variant-driven visual atoms: colour/status variants, chips, badges, and +//// buttons. + +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +pub type Variant { + Ink + Owned + Wanted + Accent + Surface + Alert +} + +fn variant_suffix(variant: Variant) -> String { + case variant { + Ink -> "ink" + Owned -> "owned" + Wanted -> "wanted" + Accent -> "accent" + Surface -> "surface" + Alert -> "alert" + } +} + +pub type Btn { + Primary + Danger + Dark + Ghost +} + +fn btn_class(variant: Btn) -> String { + case variant { + Primary -> "btn btn--primary" + Danger -> "btn btn--danger" + Dark -> "btn btn--dark" + Ghost -> "btn btn--ghost" + } +} + +pub fn chip(label: String, variant: Variant) -> Element(msg) { + html.span([attr.class("chip chip--" <> variant_suffix(variant))], [ + text(label), + ]) +} + +/// A clickable filter chip; renders muted when inactive. +pub fn chip_button( + label: String, + variant: Variant, + active: Bool, + on_click: msg, +) -> Element(msg) { + let class = case active { + True -> "chip chip--" <> variant_suffix(variant) + False -> "chip chip--off" + } + html.button([attr.class(class), event.on_click(on_click)], [text(label)]) +} + +pub fn badge(label: String, variant: Variant) -> Element(msg) { + html.span([attr.class("badge badge--" <> variant_suffix(variant))], [ + text(label), + ]) +} + +/// OWNED/WANTED pill derived from an entry status string. +pub fn status_badge(status: String) -> Element(msg) { + case status { + "wanted" -> badge("WANTED", Wanted) + _ -> badge("OWNED", Owned) + } +} + +pub fn button( + label: String, + variant: Btn, + attrs: List(attr.Attribute(msg)), +) -> Element(msg) { + html.button([attr.class(btn_class(variant)), ..attrs], [text(label)]) +} + +pub fn link_button(label: String, variant: Btn, href: String) -> Element(msg) { + html.a([attr.class(btn_class(variant)), attr.href(href)], [text(label)]) +} diff --git a/web/src/at_record_web/ui/covers.gleam b/web/src/at_record_web/ui/covers.gleam new file mode 100644 index 0000000..bb23d2f --- /dev/null +++ b/web/src/at_record_web/ui/covers.gleam @@ -0,0 +1,121 @@ +//// Cover art tiles, cards, and rows: colour-hashed letter tiles with an +//// optional real cover image, plus the containers built on top of them. + +import at_record_web/ui/controls as ctl +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html + +const tile_colors = ["owned", "wanted", "accent", "sage"] + +pub fn card(children: List(Element(msg))) -> Element(msg) { + html.section([attr.class("card")], children) +} + +/// A cover image over the letter-tile fallback (stable colour hashed from title+artist). +pub fn tile_art(thumb: Option(String), title: String) -> Element(msg) { + case thumb { + Some(src) -> + element.fragment([ + text(cover_initial(title)), + html.img([ + attr.class("cover-tile__img"), + attr.src(src), + attr.alt(title <> " cover"), + ]), + ]) + None -> text(cover_initial(title)) + } +} + +pub fn cover_color(seed: String) -> String { + let sum = + string.to_utf_codepoints(seed) + |> list.fold(0, fn(acc, cp) { acc + string.utf_codepoint_to_int(cp) }) + case list.drop(tile_colors, sum % 4) { + [color, ..] -> color + [] -> "owned" + } +} + +pub fn cover_initial(title: String) -> String { + case string.first(string.trim(title)) { + Ok(char) -> string.uppercase(char) + Error(_) -> "?" + } +} + +/// Grid card: colour tile with a status badge, plus the title/artist/format. +pub fn cover_card( + href: String, + seed: String, + status: String, + title: String, + artist: String, + fmt: String, + thumb: Option(String), +) -> Element(msg) { + let color = cover_color(seed) + html.a([attr.class("cover-card"), attr.href(href)], [ + html.div([attr.class("cover-tile cover-tile--card cover-tile--" <> color)], [ + tile_art(thumb, title), + html.span([attr.class("cover-tile__badge")], [ctl.status_badge(status)]), + ]), + html.div([attr.class("cover-card__info")], [ + html.span([attr.class("cover-card__title")], [text(title)]), + html.span([attr.class("cover-card__artist")], [text(artist)]), + html.span([attr.class("cover-card__fmt")], [text(fmt)]), + ]), + ]) +} + +/// List row: a 48px cover thumb beside the title/meta, with the status +/// sticker right-aligned; the list display's sibling to `cover_card`. +pub fn cover_row( + href: String, + seed: String, + status: String, + title: String, + meta: String, + thumb: Option(String), +) -> Element(msg) { + let color = cover_color(seed) + html.a([attr.class("row"), attr.href(href)], [ + html.div([attr.class("cover-tile cover-tile--row cover-tile--" <> color)], [ + tile_art(thumb, title), + ]), + html.div([attr.class("row__info")], [ + html.span([attr.class("row__title")], [text(title)]), + html.span([attr.class("row__meta")], [text(meta)]), + ]), + html.span([attr.class("row__status")], [ctl.status_badge(status)]), + ]) +} + +/// Full-bleed detail cover band with status sticker and a format sticker. +pub fn detail_cover( + seed: String, + title: String, + status: String, + fmt: String, + thumb: Option(String), +) -> Element(msg) { + let color = cover_color(seed) + html.div([attr.class("cover-tile cover-tile--detail cover-tile--" <> color)], [ + tile_art(thumb, title), + html.span([attr.class("cover-tile__status")], [ctl.status_badge(status)]), + html.span([attr.class("cover-tile__fmt")], [text(fmt)]), + ]) +} + +/// Small thumbnail for list rows; falls back to an empty block. +pub fn thumb(url: Option(String)) -> Element(msg) { + case url { + Some(src) -> + html.img([attr.class("cover"), attr.src(src), attr.alt("cover")]) + None -> html.div([attr.class("cover cover-empty")], []) + } +} diff --git a/web/src/at_record_web/ui/forms.gleam b/web/src/at_record_web/ui/forms.gleam new file mode 100644 index 0000000..a9aeacd --- /dev/null +++ b/web/src/at_record_web/ui/forms.gleam @@ -0,0 +1,64 @@ +//// Form controls: labeled fields, debounced suggestion lists, section +//// labels, and segmented toggles. + +import gleam/list +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +pub fn field( + label: String, + placeholder: String, + value: String, + on_input: fn(String) -> msg, + input_type: String, +) -> Element(msg) { + html.label([attr.class("field")], [ + html.span([], [text(label)]), + html.input([ + attr.type_(input_type), + attr.placeholder(placeholder), + attr.value(value), + event.on_input(on_input), + ]), + ]) +} + +/// A dropdown list under a debounced text field (title/handle autocomplete): +/// empty while there's nothing to suggest, else the first 6 rows. +pub fn suggestions(items: List(a), row: fn(a) -> Element(msg)) -> Element(msg) { + case items { + [] -> element.none() + items -> + html.ul( + [attr.class("suggestions dropdown-panel")], + items |> list.take(6) |> list.map(row), + ) + } +} + +pub fn section_label(label: String) -> Element(msg) { + html.p([attr.class("section-label")], [text(label)]) +} + +/// A two-up (or n-up) toggle; `options` are `#(value, label)` pairs. +pub fn segment( + options: List(#(String, String)), + selected: String, + on_select: fn(String) -> msg, +) -> Element(msg) { + html.div( + [attr.class("segment")], + list.map(options, fn(option) { + let #(value, label) = option + let class = case value == selected { + True -> "is-active" + False -> "" + } + html.button([attr.class(class), event.on_click(on_select(value))], [ + text(label), + ]) + }), + ) +} diff --git a/web/src/at_record_web/ui/record_detail.gleam b/web/src/at_record_web/ui/record_detail.gleam new file mode 100644 index 0000000..b30caea --- /dev/null +++ b/web/src/at_record_web/ui/record_detail.gleam @@ -0,0 +1,57 @@ +//// Record-detail bits: star ratings, grade cards, key/value rows, notes, +//// and the shared record-grade vocabulary. + +import gleam/int +import gleam/string +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html + +pub fn stars(rating: Int) -> Element(msg) { + let filled = int.clamp(rating, 0, 5) + let glyphs = string.repeat("★", filled) <> string.repeat("☆", 5 - filled) + html.div([attr.class("stars")], [ + html.span([attr.class("stars__glyphs")], [text(glyphs)]), + html.span([attr.class("stars__count")], [ + text(int.to_string(filled) <> " / 5"), + ]), + ]) +} + +pub fn grade_card(label: String, grade: String, name: String) -> Element(msg) { + html.div([attr.class("grade")], [ + html.span([attr.class("grade__label")], [text(label)]), + html.span([attr.class("grade__value")], [text(grade)]), + html.span([attr.class("grade__name")], [text(name)]), + ]) +} + +pub fn detail_row(label: String, value: String) -> Element(msg) { + html.div([attr.class("detail-row")], [ + html.span([attr.class("detail-row__label")], [text(label)]), + html.span([attr.class("detail-row__value")], [text(value)]), + ]) +} + +pub fn notes(body: String) -> Element(msg) { + html.div([attr.class("notes")], [ + html.p([attr.class("notes__label")], [text("NOTES")]), + html.p([attr.class("notes__text")], [text(body)]), + ]) +} + +pub const grades = ["M", "NM", "VG+", "VG", "G+", "G", "F", "P"] + +pub fn grade_name(grade: String) -> String { + case grade { + "M" -> "Mint" + "NM" -> "Near Mint" + "VG+" -> "Very Good Plus" + "VG" -> "Very Good" + "G+" -> "Good Plus" + "G" -> "Good" + "F" -> "Fair" + "P" -> "Poor" + _ -> "—" + } +} diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index 04311da..5422fb9 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -16,7 +16,7 @@ import at_record_web/pages/record import at_record_web/pages/scan import at_record_web/pages/settings import at_record_web/route -import at_record_web/ui/components as c +import at_record_web/ui/app_bar as bar import gleam/list import gleam/option.{type Option, None, Some} import lustre/attribute as attr @@ -65,17 +65,17 @@ fn find_entry(items: List(Entry), entry_id: String) -> Option(Entry) { fn app_bar(model: Model, handle: String) -> Element(Msg) { case model.route { Crate -> - c.app_bar( - c.brand("AT·RECORD"), + bar.app_bar( + bar.brand("AT·RECORD"), element.none(), crate_actions(handle, model.avatar), ) Add -> back_bar("ADD RECORD") Scan -> - c.app_bar( - c.icon_link("←", "/"), - c.bar_title("SCAN BARCODE"), - c.icon_link("✎", route.to_path(Add)), + bar.app_bar( + bar.icon_link("←", "/"), + bar.bar_title("SCAN BARCODE"), + bar.icon_link("✎", route.to_path(Add)), ) Browse -> back_bar("BROWSE") EditInbox -> back_bar("EDIT INBOX") @@ -85,23 +85,27 @@ fn app_bar(model: Model, handle: String) -> Element(Msg) { } fn back_bar(title: String) -> Element(Msg) { - c.app_bar(c.icon_link("←", "/"), c.bar_title(title), c.icon_disabled("⋯")) + bar.app_bar( + bar.icon_link("←", "/"), + bar.bar_title(title), + bar.icon_disabled("⋯"), + ) } fn crate_actions(handle: String, avatar: Option(String)) -> Element(Msg) { html.div([attr.class("app-bar__right")], [ - c.icon_link("⌗", "/browse"), - c.icon_link("⦀", "/scan"), + bar.icon_link("⌗", "/browse"), + bar.icon_link("⦀", "/scan"), avatar_link(handle, avatar), ]) } /// The avatar is a plain nav anchor to Settings (which holds the account -/// actions), mirroring how `c.icon_link` builds its bar anchors. +/// actions), mirroring how `bar.icon_link` builds its bar anchors. fn avatar_link(handle: String, avatar: Option(String)) -> Element(Msg) { html.a( [attr.class("avatar"), attr.href("/settings"), attr.title("Settings")], - [c.avatar_content(avatar, handle)], + [bar.avatar_content(avatar, handle)], ) } -- 2.51.2