diff --git a/web/gleam.toml b/web/gleam.toml index e72da32..d76f58b 100644 --- a/web/gleam.toml +++ b/web/gleam.toml @@ -9,6 +9,7 @@ lustre = ">= 5.7.0 and < 6.0.0" rsvp = ">= 2.0.0 and < 3.0.0" gleam_json = ">= 3.1.0 and < 4.0.0" at_record_shared = { path = "../shared" } +modem = ">= 2.1.3 and < 3.0.0" [dev_dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/web/manifest.toml b/web/manifest.toml index 697042d..32d83fe 100644 --- a/web/manifest.toml +++ b/web/manifest.toml @@ -40,6 +40,7 @@ packages = [ { name = "lustre_dev_tools", version = "2.3.6", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "7A650A107C7E767D902A8CB608460B927DE0CDD47D5BA04DED71F9F622FE6AD9" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, + { name = "modem", version = "2.1.3", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "F03560273C4E857025E7628BCE1EBA8B7E9A1A81A73CA998FC29FD1CED188968" }, { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, { name = "polly", version = "3.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_erlang", "gleam_otp", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "51FB565D81FF6212FDF3306D44419601F2A7C4EDD1F00FC9DA5C376A00AED4FE" }, { name = "rsvp", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "A38F2DB9657E84A584278DB50FD903693FF370C22C191753B773546FEB2465F0" }, @@ -57,4 +58,5 @@ gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } lustre = { version = ">= 5.7.0 and < 6.0.0" } lustre_dev_tools = { version = ">= 2.3.6 and < 3.0.0" } +modem = { version = ">= 2.1.3 and < 3.0.0" } rsvp = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/web/priv/static/app.css b/web/priv/static/app.css index c288c04..dfff100 100644 --- a/web/priv/static/app.css +++ b/web/priv/static/app.css @@ -1,79 +1,283 @@ +/* AT·RECORD — warm analog / neo-brutalist theme. + Tokens mirror the Figma semantic variables; shadows are hard (no blur), + borders are 2px ink, radius is 0 everywhere. */ :root { - --bg: #0f0d0b; - --panel: #1a1714; - --ink: #f4ece1; - --muted: #b3a796; - --line: #2e2823; - --accent: #e0773a; - --danger: #c2553f; -} + --bg: #f1e7d2; + --surface: #fbf5e7; + --ink: #2a2017; + --ink-muted: #6a5a41; + --line: #dbc9aa; + --owned: #3e5b39; + --accent: #c99a3a; + --wanted: #c0673b; + --sage: #8a9a6b; + + --shadow-xs: 2px 2px 0 0 var(--ink); + --shadow-sm: 3px 3px 0 0 var(--ink); + --shadow-md: 4px 4px 0 0 var(--ink); + + --display: "Anton", ui-sans-serif, system-ui, sans-serif; + --serif: "Fraunces", Georgia, "Times New Roman", serif; + --mono: "Space Mono", ui-monospace, "SF Mono", Menlo, monospace; +} + * { box-sizing: border-box; + border-radius: 0; } + +html, body { margin: 0; - font: 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +} + +body { + font: 15px/1.5 var(--mono); background: var(--bg); color: var(--ink); -webkit-font-smoothing: antialiased; } -.wrap { - max-width: 640px; + +/* The phone frame. Full-bleed on mobile, a centered 390px card on desktop. */ +.app { + width: 100%; + max-width: 390px; margin: 0 auto; - padding: 2.5rem 1.25rem 4rem; + min-height: 100dvh; + background: var(--bg); + position: relative; + padding-bottom: 96px; +} +@media (min-width: 430px) { + body { + padding: 24px 0; + } + .app { + border: 2px solid var(--ink); + box-shadow: var(--shadow-md); + min-height: calc(100dvh - 48px); + } +} + +/* --- status bar ------------------------------------------------------- */ +.status-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px 8px; + font: 700 13px/1 var(--mono); } -h1 { - font-size: 2.4rem; - margin: 0; - letter-spacing: -0.03em; - font-family: ui-monospace, "SF Mono", Menlo, monospace; +.status-bar .dots { + font-size: 12px; + letter-spacing: 1px; } -h1::first-letter { - color: var(--accent); + +/* --- app bar ---------------------------------------------------------- */ +.app-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 18px 14px; + border-bottom: 2px solid var(--ink); } -.tagline { - color: var(--muted); - margin: 0.25rem 0 2rem; +.app-bar__title { + font: 400 22px/1 var(--display); + letter-spacing: 0.5px; } -h2 { - font-size: 1rem; +.app-bar__title--sub { + font: 700 13px/1 var(--mono); + letter-spacing: 1px; text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--muted); - margin: 0 0 1rem; } +.app-bar__right { + display: flex; + align-items: center; + gap: 10px; +} + +.icon-btn { + width: 34px; + height: 34px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--surface); + border: 2px solid var(--ink); + box-shadow: var(--shadow-xs); + color: var(--ink); + font: 700 15px/1 var(--mono); + cursor: pointer; + text-decoration: none; +} +.icon-btn:disabled { + cursor: default; + opacity: 0.65; +} + +.avatar { + width: 32px; + height: 32px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--owned); + border: 2px solid var(--ink); + color: var(--bg); + font: 400 14px/1 var(--display); + text-decoration: none; + cursor: pointer; +} + +/* --- chips / badges --------------------------------------------------- */ +.chip, +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + border: 2px solid var(--ink); + font: 700 11px/1 var(--mono); + letter-spacing: 0.4px; + text-transform: uppercase; + cursor: pointer; + text-decoration: none; +} +.chip { + padding: 6px 11px; + box-shadow: var(--shadow-xs); +} +.badge { + padding: 3px 7px; + font-size: 9px; + cursor: default; +} +.chip--ink, +.badge--ink { + background: var(--ink); + color: var(--surface); +} +.chip--owned, +.badge--owned { + background: var(--owned); + color: var(--surface); +} +.chip--wanted, +.badge--wanted { + background: var(--wanted); + color: var(--surface); +} +.chip--accent, +.badge--accent { + background: var(--accent); + color: var(--ink); +} +.chip--surface, +.badge--surface { + background: var(--surface); + color: var(--ink); +} +/* a muted, unselected filter chip */ +.chip--off { + background: var(--surface); + color: var(--ink-muted); + box-shadow: none; +} + +/* --- card ------------------------------------------------------------- */ .card { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 14px; - padding: 1.5rem; - margin-bottom: 1.5rem; + background: var(--surface); + border: 2px solid var(--ink); + box-shadow: var(--shadow-sm); + padding: 16px; + margin-bottom: 16px; +} + +/* --- buttons ---------------------------------------------------------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 2px solid var(--ink); + box-shadow: var(--shadow-sm); + padding: 13px 16px; + font: 700 13px/1 var(--mono); + letter-spacing: 0.5px; + text-transform: uppercase; + cursor: pointer; + text-decoration: none; + color: var(--ink); + background: var(--surface); + transition: + transform 0.04s ease, + box-shadow 0.04s ease; +} +.btn:active { + transform: translate(2px, 2px); + box-shadow: var(--shadow-xs); +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn--primary { + background: var(--accent); + color: var(--ink); +} +.btn--danger { + background: var(--wanted); + color: var(--surface); +} +.btn--dark { + background: var(--ink); + color: var(--surface); +} +.btn--ghost { + box-shadow: none; + background: transparent; +} +.btn--block { + width: 100%; } -label { + +/* --- fields ----------------------------------------------------------- */ +.field { display: block; - margin-bottom: 0.9rem; + margin-bottom: 16px; } -label > span { +.field > span, +.section-label { display: block; - font-size: 0.8rem; - color: var(--muted); - margin-bottom: 0.3rem; + font: 700 11px/1.2 var(--mono); + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--ink-muted); + margin-bottom: 8px; +} +.section-label { + margin-bottom: 10px; } input, -select { +select, +textarea { width: 100%; - padding: 0.6rem 0.7rem; - border-radius: 9px; - border: 1px solid var(--line); - background: #110f0d; + padding: 13px 14px; + border: 2px solid var(--ink); + background: var(--surface); color: var(--ink); - font: inherit; + font: 400 15px/1.3 var(--mono); +} +textarea { + min-height: 84px; + resize: vertical; } input:focus, -select:focus { - outline: 2px solid var(--accent); - outline-offset: 1px; - border-color: transparent; +select:focus, +textarea:focus { + outline: none; + box-shadow: var(--shadow-xs); } input[type="number"] { -moz-appearance: textfield; @@ -84,100 +288,477 @@ input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } -button, -a.primary { - font: inherit; + +.field-row { + display: flex; + gap: 12px; +} +.field-row > .field { + flex: 1; + min-width: 0; +} + +/* --- segment toggle (OWNED | WANTED) ---------------------------------- */ +.segment { + display: flex; + border: 2px solid var(--ink); + box-shadow: var(--shadow-xs); +} +.segment button { + flex: 1; + border: none; + background: var(--surface); + color: var(--ink-muted); + padding: 12px; + font: 700 13px/1 var(--mono); + letter-spacing: 0.5px; + text-transform: uppercase; cursor: pointer; - border: 1px solid var(--line); - background: #221d18; - color: var(--ink); - padding: 0.55rem 1rem; - border-radius: 9px; - display: inline-block; +} +.segment button + button { + border-left: 2px solid var(--ink); +} +.segment button.is-active { + background: var(--owned); + color: var(--surface); +} + +/* --- hero (crate) ----------------------------------------------------- */ +.hero { + position: relative; + height: 160px; + border-bottom: 3px solid var(--ink); + overflow: hidden; + background: linear-gradient(135deg, var(--owned), #2c4129 60%, var(--ink)); +} +.hero__sticker { + position: absolute; + left: 16px; + top: 92px; + background: var(--ink); + border: 2px solid var(--bg); + color: var(--bg); + padding: 6px 12px; + font: 400 28px/1 var(--display); +} +.hero__count { + position: absolute; + left: 18px; + top: 134px; + color: var(--bg); + font: italic 900 13px/1 var(--serif); +} +.hero__badge { + position: absolute; + right: 16px; + top: 16px; +} + +/* --- toolbar / tags --------------------------------------------------- */ +.body { + padding: 16px; +} +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.tags { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +/* --- cover grid ------------------------------------------------------- */ +.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + align-items: start; + gap: 16px 14px; +} +.cover-card { + display: flex; + flex-direction: column; + min-width: 0; + background: var(--surface); + border: 2px solid var(--ink); + box-shadow: var(--shadow-md); text-decoration: none; - text-align: center; + color: var(--ink); } -button:disabled { - opacity: 0.5; - cursor: not-allowed; +.cover-card__info { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 10px; +} +.cover-card__title { + font: 700 13px/1.2 var(--mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cover-card__artist { + font: 400 11px/1.2 var(--mono); + color: var(--ink-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cover-card__fmt { + font: 700 10px/1.2 var(--mono); + letter-spacing: 0.2px; +} + +/* --- cover tile (the Anton letter on a colour block) ------------------ */ +.cover-tile { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + overflow: hidden; + color: var(--surface); + font-family: var(--display); + font-weight: 400; + border-bottom: 2px solid var(--ink); +} +.cover-tile img { + width: 100%; + height: 100%; + object-fit: cover; +} +.cover-tile--card { + height: 150px; + font-size: 84px; +} +.cover-tile--detail { + height: 300px; + font-size: 190px; + border-bottom-width: 3px; } -button.primary, -a.primary { +.cover-tile--owned { + background: var(--owned); +} +.cover-tile--wanted { + background: var(--wanted); +} +.cover-tile--accent { background: var(--accent); - border-color: var(--accent); - color: #160d06; - font-weight: 600; + color: var(--ink); } -button.danger { - background: transparent; - border-color: transparent; - color: var(--danger); - padding: 0.4rem 0.5rem; +.cover-tile--sage { + background: var(--sage); + color: var(--ink); } -button.danger:hover { - background: #2a1714; +.cover-tile__badge { + position: absolute; + left: 8px; + top: 8px; + box-shadow: var(--shadow-xs); +} +.cover-tile__fmt { + position: absolute; + left: 16px; + bottom: 16px; + background: var(--ink); + color: var(--bg); + padding: 5px 10px; + font: 700 11px/1 var(--mono); + letter-spacing: 0.5px; +} +.cover-tile__status { + position: absolute; + left: 16px; + top: 16px; + box-shadow: var(--shadow-sm); +} + +/* --- fab -------------------------------------------------------------- */ +.fab { + position: fixed; + right: max(16px, calc(50vw - 195px + 16px)); + bottom: 24px; + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--accent); + border: 2px solid var(--ink); + box-shadow: var(--shadow-md); + color: var(--ink); + padding: 14px 20px; + text-decoration: none; + z-index: 5; } -.hint { - font-size: 0.8rem; - color: var(--muted); - margin: 0.75rem 0 0; +.fab__plus { + font: 400 22px/1 var(--display); } -.notice { - background: #2a1714; - border: 1px solid var(--danger); - color: #f2cabd; - border-radius: 9px; - padding: 0.6rem 0.8rem; - margin-bottom: 1.5rem; +.fab__label { + font: 700 13px/1 var(--mono); + letter-spacing: 0.5px; } -.topbar { + +/* --- record detail ---------------------------------------------------- */ +.titleblock { + display: flex; + flex-direction: column; + gap: 6px; + padding: 18px 18px 4px; +} +.titleblock__title { + font: 900 26px/1.1 var(--serif); + margin: 0; +} +.titleblock__artist { + font: 700 15px/1 var(--mono); + color: var(--ink-muted); +} +.stars { + display: flex; + align-items: center; + gap: 8px; + padding-top: 4px; +} +.stars__glyphs { + font: 700 18px/1 var(--mono); + color: var(--accent); + letter-spacing: 1px; +} +.stars__count { + font: 400 12px/1 var(--mono); + color: var(--ink-muted); +} + +.detail-body { + display: flex; + flex-direction: column; + gap: 18px; + padding: 16px 18px 20px; +} +.grades { + display: flex; + gap: 12px; +} +.grade { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--surface); + border: 2px solid var(--ink); + box-shadow: var(--shadow-sm); + padding: 12px 14px; +} +.grade__label { + font: 700 10px/1.2 var(--mono); + letter-spacing: 0.8px; + color: var(--ink-muted); +} +.grade__value { + font: 400 34px/1 var(--display); + color: var(--owned); +} +.grade__name { + font: 400 10px/1.2 var(--mono); + color: var(--ink-muted); +} + +.detail-rows { + background: var(--surface); + border: 2px solid var(--ink); + box-shadow: var(--shadow-sm); + padding: 0 14px; +} +.detail-row { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + justify-content: space-between; + padding: 11px 0; + border-bottom: 1px solid var(--line); + font: 700 12px/1.3 var(--mono); +} +.detail-row:last-child { + border-bottom: none; +} +.detail-row__label { + color: var(--ink-muted); + letter-spacing: 0.5px; +} +.detail-row__value { + color: var(--ink); +} + +.notes { + background: var(--owned); + border: 2px solid var(--ink); + box-shadow: var(--shadow-sm); + padding: 14px 16px 16px; +} +.notes__label { + font: 700 11px/1 var(--mono); + letter-spacing: 1.5px; + color: var(--accent); + margin: 0 0 8px; +} +.notes__text { + font: italic 900 16px/1.4 var(--serif); + color: var(--surface); + margin: 0; +} + +.actions { + display: flex; + gap: 12px; +} +.actions .btn { + border-width: 2.5px; + box-shadow: var(--shadow-md); + padding: 15px 18px; +} +.actions .btn--primary { + flex: 1; +} + +.edit-panel { + display: flex; + gap: 12px; + margin-top: 4px; +} +.edit-panel .field { + flex: 1; + margin-bottom: 0; +} + +/* --- timeline --------------------------------------------------------- */ +.timeline { + list-style: none; + margin: 16px 0 0; + padding: 0; + font: 400 13px/1.4 var(--mono); +} +.timeline li { + display: flex; + gap: 12px; + padding: 8px 0; + border-top: 1px solid var(--line); +} +.timeline li:first-child { + border-top: none; +} +.timeline-when { + color: var(--ink-muted); + flex-shrink: 0; +} + +/* --- discogs search / infinite scroll --------------------------------- */ +.search-row { + display: flex; + gap: 8px; } -.topbar span { - font-family: ui-monospace, monospace; - color: var(--muted); +.search-row input { + flex: 1; } -.shelf { +.search-row .btn { + flex-shrink: 0; +} +.results-scroll { + margin-top: 12px; + max-height: 320px; + overflow-y: auto; + border: 2px solid var(--ink); + background: var(--surface); +} +.results { list-style: none; margin: 0; padding: 0; } -.item { +.results li { display: flex; - justify-content: space-between; align-items: center; - gap: 1rem; - padding: 0.9rem 0; + gap: 12px; + padding: 10px 12px; border-top: 1px solid var(--line); } +.results li:first-child { + border-top: none; +} +.result-main { + flex: 1; + min-width: 0; +} +.result-main strong { + font-weight: 700; +} +.load-more { + text-align: center; + margin: 8px 0; +} + +/* small cover thumb used in result rows */ +.cover { + width: 44px; + height: 44px; + flex-shrink: 0; + object-fit: cover; + border: 2px solid var(--ink); + background: var(--bg); +} +.cover-empty { + display: inline-block; +} + +/* --- misc text -------------------------------------------------------- */ +.hint { + font-size: 13px; + color: var(--ink-muted); + margin: 8px 0 0; +} .item-meta { - font-size: 0.82rem; - color: var(--muted); - margin-top: 0.2rem; + font-size: 12px; + color: var(--ink-muted); + margin-top: 2px; } .empty { - color: var(--muted); + color: var(--ink-muted); + padding: 24px 4px; } -.tabs { +.notice { + background: var(--wanted); + border: 2px solid var(--ink); + box-shadow: var(--shadow-xs); + color: var(--surface); + padding: 12px 14px; + margin: 16px; + font-weight: 700; +} +.checkbox { display: flex; - gap: 0.25rem; - margin: 1rem 0 0.5rem; + align-items: center; + gap: 8px; + margin: 12px 0 0; } -.tab { - background: transparent; - border-color: transparent; - color: var(--muted); - padding: 0.4rem 0.8rem; +.checkbox input { + width: auto; } -.tab.active { - background: #221d18; - border-color: var(--line); - color: var(--ink); +.checkbox span { + font-size: 13px; + color: var(--ink-muted); } -.item-actions { - display: flex; - gap: 0.4rem; + +/* --- signed-out / login ----------------------------------------------- */ +.signin { + padding: 24px 16px; +} +.signin h2 { + font: 400 30px/1 var(--display); + margin: 0 0 4px; +} +.signin .tagline { + color: var(--ink-muted); + margin: 0 0 20px; + font-size: 13px; } diff --git a/web/priv/static/index.html b/web/priv/static/index.html index 34e1aa1..8cb4cde 100644 --- a/web/priv/static/index.html +++ b/web/priv/static/index.html @@ -3,14 +3,20 @@ - + at-record + + +
-

Loading at-record…

+

Loading at-record…

diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam index d9d8e33..46ad6b7 100644 --- a/web/src/at_record_web.gleam +++ b/web/src/at_record_web.gleam @@ -1,15 +1,17 @@ import at_record_web/browser import at_record_web/effects.{load_shelf} -import at_record_web/model.{type Model, LoggedOut, Model, blank_form} -import at_record_web/msg.{type Msg} +import at_record_web/model.{type Model, Crate, LoggedOut, Model, blank_form} +import at_record_web/msg.{type Msg, OnRouteChange} +import at_record_web/route import at_record_web/update.{update} import at_record_web/view.{view} import gleam/list import gleam/option.{type Option, None, Some} import gleam/string -import gleam/uri +import gleam/uri.{type Uri} import lustre import lustre/effect.{type Effect} +import modem pub fn main() -> Nil { let app = lustre.application(init, update, view) @@ -19,24 +21,43 @@ pub fn main() -> Nil { fn init(_flags) -> #(Model, Effect(Msg)) { let notice = oauth_error() + let active_route = case modem.initial_uri() { + Ok(uri) -> route.parse(uri) + Error(_) -> Crate + } let model = Model( auth: LoggedOut, + route: active_route, login_handle: "", view: "owned", items: [], form: blank_form(), notice:, busy: True, + selected: None, + editing: False, + timeline: [], + discogs_query: "", + discogs_results: [], + discogs_searching: False, + discogs_vinyl_only: True, + discogs_page: 1, + discogs_pages: 0, ) - // Clear a callback ?error= from the URL so it doesn't linger across refreshes. + // modem.init wires up URL routing; clear_query drops a lingering callback ?error=. + let routing = modem.init(on_url_change) let startup = case notice { - Some(_) -> effect.batch([load_shelf("owned"), clear_query()]) - None -> load_shelf("owned") + Some(_) -> effect.batch([routing, load_shelf("owned"), clear_query()]) + None -> effect.batch([routing, load_shelf("owned")]) } #(model, startup) } +fn on_url_change(uri: Uri) -> Msg { + OnRouteChange(route.parse(uri)) +} + fn oauth_error() -> Option(String) { case uri.parse_query(string.drop_start(browser.current_search(), 1)) { Ok(params) -> diff --git a/web/src/at_record_web/browser.gleam b/web/src/at_record_web/browser.gleam index a14d6c5..321ea20 100644 --- a/web/src/at_record_web/browser.gleam +++ b/web/src/at_record_web/browser.gleam @@ -5,3 +5,6 @@ pub fn current_search() -> String @external(javascript, "./ffi.mjs", "clearQuery") pub fn clear_query() -> Nil + +@external(javascript, "./ffi.mjs", "navigate") +pub fn navigate(url: String) -> Nil diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 19c2b44..b16bfdb 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -1,13 +1,20 @@ import at_record/gen/defs -import at_record_web/model.{type Entry, type Form, Entry} +import at_record/gen/shelf/entry.{shelf_entry_decoder} +import at_record_web/browser +import at_record_web/model.{ + type DiscogsResult, type Entry, type Form, DiscogsResult, DiscogsSearchPage, + Entry, +} import at_record_web/msg.{ - type Msg, GotAction, GotAdd, GotLogout, GotShelf, ShelfData, + type Msg, GotAction, GotAdd, GotDiscogs, GotLogout, GotShelf, GotTimeline, + ShelfData, } import gleam/dynamic/decode import gleam/int import gleam/json.{type Json} import gleam/list import gleam/option.{None} +import gleam/uri import lustre/effect.{type Effect} import rsvp @@ -63,6 +70,63 @@ pub fn logout() -> Effect(Msg) { ) } +/// Full-page navigation to the OAuth start. Not an on purpose: modem would +/// intercept a same-origin link and route it client-side instead of hitting the +/// server, so the PDS redirect would never fire. +pub fn oauth_login(handle: String) -> Effect(Msg) { + let url = "/api/oauth/login?handle=" <> uri.percent_encode(handle) + effect.from(fn(_dispatch) { browser.navigate(url) }) +} + +pub fn discogs_search( + query: String, + vinyl_only: Bool, + page: Int, +) -> Effect(Msg) { + let decoder = { + use results <- decode.field("results", decode.list(discogs_decoder())) + use page <- decode.field("page", decode.int) + use pages <- decode.field("pages", decode.int) + decode.success(DiscogsSearchPage(results:, page:, pages:)) + } + let format = case vinyl_only { + True -> "&format=Vinyl" + False -> "" + } + let url = + "/api/discogs/search?q=" + <> uri.percent_encode(query) + <> "&page=" + <> int.to_string(page) + <> format + rsvp.get(url, rsvp.expect_json(decoder, GotDiscogs)) +} + +fn discogs_decoder() -> decode.Decoder(DiscogsResult) { + use discogs_id <- decode.field("discogsId", decode.int) + use title <- decode.field("title", decode.string) + use artist <- decode.field("artist", decode.string) + use year <- decode.optional_field("year", None, decode.optional(decode.int)) + use format <- decode.optional_field( + "format", + None, + decode.optional(decode.string), + ) + use thumb_url <- decode.optional_field( + "thumbUrl", + None, + decode.optional(decode.string), + ) + decode.success(DiscogsResult( + discogs_id:, + title:, + artist:, + year:, + format:, + thumb_url:, + )) +} + pub fn add_item(form: Form) -> Effect(Msg) { rsvp.post( "/api/shelf", @@ -71,15 +135,43 @@ pub fn add_item(form: Form) -> Effect(Msg) { ) } -/// Append an action event (acquired/sold/dropped/…) to an entry. -pub fn entry_action(entry_id: String, action: String) -> Effect(Msg) { +/// Append an action event with arbitrary extra fields to an entry. +fn entry_event( + entry_id: String, + action: String, + fields: List(#(String, Json)), +) -> Effect(Msg) { rsvp.post( "/api/shelf/" <> entry_id, - json.object([#("action", json.string(action))]), + json.object([#("action", json.string(action)), ..fields]), rsvp.expect_json(nil_decoder(), GotAction), ) } +/// Append a bare status event (acquired/sold/dropped/…) to an entry. +pub fn entry_action(entry_id: String, action: String) -> Effect(Msg) { + entry_event(entry_id, action, []) +} + +/// Append a regraded event carrying the new media grade. +pub fn regrade(entry_id: String, grade: String) -> Effect(Msg) { + entry_event(entry_id, "regraded", [#("mediaGrade", json.string(grade))]) +} + +/// Append a rated event carrying the new 1-5 rating. +pub fn rate(entry_id: String, rating: Int) -> Effect(Msg) { + entry_event(entry_id, "rated", [#("rating", json.int(rating))]) +} + +/// Fetch the full append-only event log for one entry. +pub fn load_timeline(entry_id: String) -> Effect(Msg) { + let decoder = { + use events <- decode.field("events", decode.list(shelf_entry_decoder())) + decode.success(events) + } + rsvp.get("/api/shelf/" <> entry_id, rsvp.expect_json(decoder, GotTimeline)) +} + fn nil_decoder() -> decode.Decoder(Nil) { decode.success(Nil) } @@ -98,5 +190,13 @@ fn add_body(form: Form) -> Json { Ok(value) -> [#("year", json.int(value))] Error(_) -> [] } - json.object(list.flatten([base, format, year])) + let thumb = case form.thumb_url { + "" -> [] + value -> [#("thumbUrl", json.string(value))] + } + let discogs = case form.discogs_id { + "" -> [] + value -> [#("discogsId", json.string(value))] + } + json.object(list.flatten([base, format, year, thumb, discogs])) } diff --git a/web/src/at_record_web/ffi.mjs b/web/src/at_record_web/ffi.mjs index b132487..8eb5a4f 100644 --- a/web/src/at_record_web/ffi.mjs +++ b/web/src/at_record_web/ffi.mjs @@ -7,3 +7,12 @@ export function clearQuery() { globalThis.history.replaceState(null, "", globalThis.location.pathname); } } + +// A real full-page navigation. Used for the OAuth start: modem intercepts +// same-origin clicks (it keys off host, not rel="external"), so an anchor +// would be routed client-side and never reach the server. +export function navigate(url) { + if (globalThis.location) { + globalThis.location.assign(url); + } +} diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index e25b0e7..5b8e15c 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -1,4 +1,5 @@ import at_record/gen/defs.{type Snapshot} +import at_record/gen/shelf/entry.{type ShelfEntry} import gleam/option.{type Option} pub type Auth { @@ -6,6 +7,13 @@ pub type Auth { LoggedIn(handle: String) } +/// The active page (URL-routed via modem). +pub type Route { + Crate + Add + Record(entry_id: String) +} + /// A folded crate entry (the BFF reduces the event log into these). pub type Entry { Entry( @@ -26,21 +34,58 @@ pub type Form { format: String, year: String, status: String, + thumb_url: String, + discogs_id: String, ) } pub fn blank_form() -> Form { - Form(title: "", artist: "", format: "", year: "", status: "owned") + Form( + title: "", + artist: "", + format: "", + year: "", + status: "owned", + thumb_url: "", + discogs_id: "", + ) +} + +/// A Discogs search hit, normalized by the server for the add form. +pub type DiscogsResult { + DiscogsResult( + discogs_id: Int, + title: String, + artist: String, + year: Option(Int), + format: Option(String), + thumb_url: Option(String), + ) +} + +/// One page of Discogs search results plus its position in the full set. +pub type DiscogsSearchPage { + DiscogsSearchPage(results: List(DiscogsResult), page: Int, pages: Int) } pub type Model { Model( auth: Auth, + route: Route, login_handle: String, view: String, items: List(Entry), form: Form, notice: Option(String), busy: Bool, + selected: Option(String), + editing: Bool, + timeline: List(ShelfEntry), + discogs_query: String, + discogs_results: List(DiscogsResult), + discogs_searching: Bool, + discogs_vinyl_only: Bool, + discogs_page: Int, + discogs_pages: Int, ) } diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 7a2ed8e..4ac908a 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -1,4 +1,7 @@ -import at_record_web/model.{type Entry} +import at_record/gen/shelf/entry.{type ShelfEntry} +import at_record_web/model.{ + type DiscogsResult, type DiscogsSearchPage, type Entry, type Route, +} import rsvp pub type ShelfData { @@ -6,7 +9,9 @@ pub type ShelfData { } pub type Msg { + OnRouteChange(Route) HandleChanged(String) + StartLogin Logout GotLogout SetView(String) @@ -19,5 +24,16 @@ pub type Msg { SubmitAdd GotAdd(Result(Nil, rsvp.Error(String))) EntryAction(entry_id: String, action: String) + ToggleEntry(entry_id: String) + ToggleEdit + GotTimeline(Result(List(ShelfEntry), rsvp.Error(String))) + Regrade(entry_id: String, grade: String) + Rate(entry_id: String, rating: String) GotAction(Result(Nil, rsvp.Error(String))) + DiscogsQuery(String) + DiscogsSearch + DiscogsVinylOnly(Bool) + DiscogsLoadMore + GotDiscogs(Result(DiscogsSearchPage, rsvp.Error(String))) + UseDiscogs(DiscogsResult) } diff --git a/web/src/at_record_web/pages/add.gleam b/web/src/at_record_web/pages/add.gleam new file mode 100644 index 0000000..99c8d79 --- /dev/null +++ b/web/src/at_record_web/pages/add.gleam @@ -0,0 +1,109 @@ +//// The Add page: seed metadata from Discogs (search + infinite-scroll results), +//// and the manual add form. + +import at_record_web/model.{type DiscogsResult, type Form, type Model} +import at_record_web/msg.{ + type Msg, DiscogsLoadMore, DiscogsQuery, DiscogsSearch, FormArtist, FormFormat, + FormStatus, FormTitle, FormYear, SubmitAdd, UseDiscogs, +} +import at_record_web/ui/components as c +import at_record_web/ui/infinite_scroll +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 +import lustre/event + +pub fn view(model: Model) -> Element(Msg) { + html.div([attr.class("body")], [ + discogs_view(model), + add_form_view(model.form, model.busy), + ]) +} + +fn discogs_view(model: Model) -> Element(Msg) { + c.card([ + c.section_label("SEED FROM DISCOGS"), + html.div([attr.class("search-row")], [ + html.input([ + attr.type_("search"), + attr.placeholder("slint spiderland"), + attr.value(model.discogs_query), + event.on_input(DiscogsQuery), + ]), + c.button( + case model.discogs_searching { + True -> "…" + False -> "SEARCH" + }, + c.Dark, + [attr.disabled(model.discogs_searching), event.on_click(DiscogsSearch)], + ), + ]), + discogs_results_view(model), + ]) +} + +fn discogs_results_view(model: Model) -> Element(Msg) { + case model.discogs_results { + [] -> element.none() + results -> + infinite_scroll.view( + list.map(results, result_row), + DiscogsLoadMore, + discogs_status(model), + ) + } +} + +fn discogs_status(model: Model) -> infinite_scroll.Status { + case model.discogs_searching, model.discogs_page < model.discogs_pages { + True, _ -> infinite_scroll.Loading + False, True -> infinite_scroll.HasMore + False, False -> infinite_scroll.Done + } +} + +fn result_row(r: DiscogsResult) -> Element(Msg) { + let meta = + [r.year |> option.map(int.to_string), r.format] + |> option.values + |> string.join(" · ") + html.li([], [ + c.thumb(r.thumb_url), + html.div([attr.class("result-main")], [ + html.strong([], [text(r.artist)]), + text(" — " <> r.title), + html.div([attr.class("item-meta")], [text(meta)]), + ]), + c.button("USE", c.Ghost, [event.on_click(UseDiscogs(r))]), + ]) +} + +fn add_form_view(form: Form, busy: Bool) -> Element(Msg) { + c.card([ + c.section_label("ADD A RECORD"), + c.field("TITLE", "Spiderland", form.title, FormTitle, "text"), + c.field("ARTIST", "Slint", form.artist, FormArtist, "text"), + html.div([attr.class("field-row")], [ + c.field("FORMAT", "LP", form.format, FormFormat, "text"), + c.field("YEAR", "1991", form.year, FormYear, "number"), + ]), + html.div([attr.class("field")], [ + c.section_label("STATUS"), + c.segment( + [#("owned", "OWNED"), #("wanted", "WANTED")], + form.status, + FormStatus, + ), + ]), + c.button("SAVE TO CRATE", c.Primary, [ + event.on_click(SubmitAdd), + attr.disabled(busy), + attr.class("btn--block"), + ]), + ]) +} diff --git a/web/src/at_record_web/pages/crate.gleam b/web/src/at_record_web/pages/crate.gleam new file mode 100644 index 0000000..0c352e2 --- /dev/null +++ b/web/src/at_record_web/pages/crate.gleam @@ -0,0 +1,87 @@ +//// The Crate page: hero band, status filter chips, and the cover-art grid. +//// Each card links to the full-screen record detail route. + +import at_record/gen/defs.{type Snapshot} +import at_record_web/model.{type Entry, type Model, Add, Record} +import at_record_web/msg.{type Msg, SetView} +import at_record_web/route +import at_record_web/ui/components as c +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) -> Element(Msg) { + html.div([], [ + hero(model.view, model.items), + html.div([attr.class("body")], [toolbar(model.view), grid(model.items)]), + c.fab("ADD", route.to_path(Add)), + ]) +} + +fn hero(view: String, items: List(Entry)) -> Element(Msg) { + html.div([attr.class("hero")], [ + html.span([attr.class("hero__sticker")], [text("THE CRATE")]), + html.span([attr.class("hero__count")], [text(hero_count(view, items))]), + html.div([attr.class("hero__badge")], [c.badge("◆ GROWING", c.Accent)]), + ]) +} + +fn hero_count(view: String, items: List(Entry)) -> String { + let n = int.to_string(list.length(items)) + case view { + "wanted" -> n <> " on your wishlist" + "history" -> n <> " in your history" + _ -> n <> " in your crate" + } +} + +fn toolbar(view: String) -> 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), + ]), + ]) +} + +fn filter( + value: String, + label: String, + variant: c.Variant, + current: String, +) -> Element(Msg) { + c.chip_button(label, variant, value == current, SetView(value)) +} + +fn grid(items: List(Entry)) -> Element(Msg) { + case items { + [] -> + html.p([attr.class("empty")], [ + text("Nothing here yet. Tap ADD to start your crate."), + ]) + _ -> html.div([attr.class("grid")], list.map(items, card)) + } +} + +fn card(entry: Entry) -> Element(Msg) { + let snap = entry.snapshot + c.cover_card( + route.to_path(Record(entry.entry_id)), + snap.title <> snap.artist_display, + entry.status, + snap.title, + snap.artist_display, + format_line(snap), + ) +} + +fn format_line(snap: Snapshot) -> String { + [snap.format, option.map(snap.year, int.to_string)] + |> option.values + |> string.join(" / ") +} diff --git a/web/src/at_record_web/pages/login.gleam b/web/src/at_record_web/pages/login.gleam new file mode 100644 index 0000000..e252922 --- /dev/null +++ b/web/src/at_record_web/pages/login.gleam @@ -0,0 +1,48 @@ +//// The signed-out page: handle entry + OAuth start, and the session-restoring +//// placeholder shown while we check for an existing session. + +import at_record_web/model.{type Model} +import at_record_web/msg.{type Msg, HandleChanged, StartLogin} +import at_record_web/ui/components as c +import gleam/string +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +pub fn restoring() -> Element(Msg) { + html.div([attr.class("signin")], [ + html.p([attr.class("hint")], [text("Restoring your session…")]), + ]) +} + +pub fn view(model: Model) -> Element(Msg) { + html.div([attr.class("signin")], [ + html.h2([], [text("AT·RECORD")]), + html.p([attr.class("tagline")], [text("your crate, stored on your PDS")]), + c.card([ + c.field( + "HANDLE OR DID", + "mokkenstorm.dev", + model.login_handle, + HandleChanged, + "text", + ), + login_action(model.login_handle), + html.p([attr.class("hint")], [ + text("You'll be sent to your PDS to authorize at-record via OAuth."), + ]), + ]), + ]) +} + +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, [ + 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 new file mode 100644 index 0000000..501e4e2 --- /dev/null +++ b/web/src/at_record_web/pages/record.gleam @@ -0,0 +1,179 @@ +//// The Record detail page: full-screen cover, condition, the details table, +//// notes, and the per-entry actions (edit grade/rating, remove) plus the event +//// timeline. Reuses the existing shelf event endpoints via Regrade/Rate/Remove. + +import at_record/gen/defs.{type Snapshot} +import at_record/gen/shelf/entry.{type ShelfEntry} +import at_record_web/model.{type Entry, type Model} +import at_record_web/msg.{type Msg, EntryAction, Rate, Regrade, ToggleEdit} +import at_record_web/ui/components as c +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 + +pub fn view(model: Model, entry: Entry) -> Element(Msg) { + let snap = entry.snapshot + html.div([], [ + c.detail_cover( + snap.title <> snap.artist_display, + snap.title, + entry.status, + cover_fmt(snap), + ), + titleblock(snap, entry.rating), + html.div([attr.class("detail-body")], [ + condition(entry), + detail_rows(entry, snap), + notes(entry.notes), + actions(entry.entry_id, model.editing), + edit_panel(entry, model.editing, model.busy), + timeline(model.timeline), + ]), + ]) +} + +fn cover_fmt(snap: Snapshot) -> String { + [snap.format, option.map(snap.year, int.to_string)] + |> option.values + |> string.join(" · ") +} + +fn titleblock(snap: Snapshot, rating: Option(Int)) -> Element(Msg) { + html.div([attr.class("titleblock")], [ + html.h1([attr.class("titleblock__title")], [text(snap.title)]), + html.span([attr.class("titleblock__artist")], [text(snap.artist_display)]), + case rating { + Some(value) -> c.stars(value) + None -> element.none() + }, + ]) +} + +fn condition(entry: Entry) -> Element(Msg) { + let grade = option.unwrap(entry.media_grade, "—") + html.div([], [ + c.section_label("CONDITION"), + html.div([attr.class("grades")], [ + c.grade_card("MEDIA", grade, c.grade_name(grade)), + ]), + ]) +} + +fn detail_rows(entry: Entry, snap: Snapshot) -> Element(Msg) { + let rows = + [ + option.map(snap.format, fn(f) { #("FORMAT", f) }), + option.map(snap.year, fn(y) { #("YEAR", int.to_string(y)) }), + option.map(entry.folder, fn(f) { #("FOLDER", f) }), + ] + |> option.values + case rows { + [] -> element.none() + _ -> + html.div( + [attr.class("detail-rows")], + list.map(rows, fn(row) { c.detail_row(row.0, row.1) }), + ) + } +} + +fn notes(notes: Option(String)) -> Element(Msg) { + case notes { + Some(body) -> c.notes(body) + None -> element.none() + } +} + +fn actions(entry_id: String, editing: Bool) -> Element(Msg) { + let edit_label = case editing { + True -> "DONE" + False -> "EDIT RECORD" + } + html.div([attr.class("actions")], [ + c.button(edit_label, c.Primary, [event.on_click(ToggleEdit)]), + c.button("REMOVE", c.Danger, [ + event.on_click(EntryAction(entry_id, "dropped")), + ]), + ]) +} + +fn edit_panel(entry: Entry, editing: Bool, busy: Bool) -> Element(Msg) { + case editing { + False -> element.none() + True -> + html.div([attr.class("edit-panel")], [ + grade_select(entry, busy), + rating_select(entry, busy), + ]) + } +} + +fn grade_select(entry: Entry, busy: Bool) -> Element(Msg) { + let current = option.unwrap(entry.media_grade, "") + let options = + list.map(["", ..c.grades], fn(g) { + let label = case g { + "" -> "Grade…" + _ -> g + } + html.option([attr.value(g), attr.selected(g == current)], label) + }) + html.label([attr.class("field")], [ + html.span([], [text("Media grade")]), + html.select( + [attr.disabled(busy), event.on_change(Regrade(entry.entry_id, _))], + options, + ), + ]) +} + +fn rating_select(entry: Entry, busy: Bool) -> Element(Msg) { + let current = option.map(entry.rating, int.to_string) |> option.unwrap("") + let options = + list.map(["", "1", "2", "3", "4", "5"], fn(r) { + let label = case r { + "" -> "Rating…" + _ -> r + } + html.option([attr.value(r), attr.selected(r == current)], label) + }) + html.label([attr.class("field")], [ + html.span([], [text("Rating")]), + html.select( + [attr.disabled(busy), event.on_change(Rate(entry.entry_id, _))], + options, + ), + ]) +} + +fn timeline(events: List(ShelfEntry)) -> Element(Msg) { + case events { + [] -> element.none() + _ -> + html.ul( + [attr.class("timeline")], + list.map(events, fn(event) { + let detail = + [ + Some(event.action), + event.media_grade, + option.map(event.rating, fn(r) { int.to_string(r) <> "★" }), + event.folder, + ] + |> option.values + |> string.join(" · ") + html.li([], [ + html.span([attr.class("timeline-when")], [ + text(string.slice(event.created_at, 0, 10)), + ]), + html.span([], [text(detail)]), + ]) + }), + ) + } +} diff --git a/web/src/at_record_web/route.gleam b/web/src/at_record_web/route.gleam new file mode 100644 index 0000000..b5db9cc --- /dev/null +++ b/web/src/at_record_web/route.gleam @@ -0,0 +1,20 @@ +//// URL <-> Route mapping for modem. + +import at_record_web/model.{type Route, Add, Crate, Record} +import gleam/uri.{type Uri} + +pub fn parse(target: Uri) -> Route { + case uri.path_segments(target.path) { + ["add"] -> Add + ["record", entry_id] -> Record(entry_id) + _ -> Crate + } +} + +pub fn to_path(route: Route) -> String { + case route { + Crate -> "/" + Add -> "/add" + Record(entry_id) -> "/record/" <> entry_id + } +} diff --git a/web/src/at_record_web/ui/components.gleam b/web/src/at_record_web/ui/components.gleam new file mode 100644 index 0000000..6e47b80 --- /dev/null +++ b/web/src/at_record_web/ui/components.gleam @@ -0,0 +1,317 @@ +//// 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 +} + +fn variant_suffix(variant: Variant) -> String { + case variant { + Ink -> "ink" + Owned -> "owned" + Wanted -> "wanted" + Accent -> "accent" + Surface -> "surface" + } +} + +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 status_bar() -> Element(msg) { + html.div([attr.class("status-bar")], [ + html.span([], [text("9:41")]), + html.span([attr.class("dots")], [text("● ● ● ▮")]), + ]) +} + +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))]) +} + +// --- 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)]) +} + +// --- 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), + ]), + ]) +} + +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"] + +/// Pick a stable tile colour from the palette by hashing a seed (title+artist). +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, +) -> 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)], [ + text(cover_initial(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)]), + ]), + ]) +} + +/// Full-bleed detail cover band with status sticker and a format sticker. +pub fn detail_cover( + seed: String, + title: String, + status: String, + fmt: String, +) -> Element(msg) { + let color = cover_color(seed) + html.div([attr.class("cover-tile cover-tile--detail cover-tile--" <> color)], [ + text(cover_initial(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/infinite_scroll.gleam b/web/src/at_record_web/ui/infinite_scroll.gleam new file mode 100644 index 0000000..931813b --- /dev/null +++ b/web/src/at_record_web/ui/infinite_scroll.gleam @@ -0,0 +1,58 @@ +//// A generic fixed-height scroll container that emits `on_load_more` as it +//// nears its bottom. Knows nothing about what it lists: callers pass rendered +//// rows, the message to fire, and a status for the footer. + +import gleam/dynamic/decode +import gleam/int +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +pub type Status { + Loading + HasMore + Done +} + +pub fn view( + rows: List(Element(msg)), + on_load_more: msg, + status: Status, +) -> Element(msg) { + html.div([attr.class("results-scroll"), on_scroll_near_bottom(on_load_more)], [ + html.ul([attr.class("results")], rows), + footer(status), + ]) +} + +fn footer(status: Status) -> Element(msg) { + case status { + Loading -> hint("Loading more…") + HasMore -> hint("Scroll for more") + Done -> element.none() + } +} + +fn hint(message: String) -> Element(msg) { + html.p([attr.class("hint load-more")], [text(message)]) +} + +/// Fire `on_load_more` when the container nears its bottom; suppress the message +/// otherwise by failing the decoder. +fn on_scroll_near_bottom(on_load_more: msg) -> attr.Attribute(msg) { + event.on("scroll", { + use top <- decode.subfield(["target", "scrollTop"], number()) + use scroll_height <- decode.subfield(["target", "scrollHeight"], number()) + use client <- decode.subfield(["target", "clientHeight"], number()) + case top +. client >=. scroll_height -. 120.0 { + True -> decode.success(on_load_more) + False -> decode.failure(on_load_more, "not near bottom") + } + }) +} + +/// DOM scroll metrics come back as plain JS numbers; accept int or float. +fn number() -> decode.Decoder(Float) { + decode.one_of(decode.float, [decode.int |> decode.map(int.to_float)]) +} diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 5446531..98db14c 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -1,12 +1,20 @@ -import at_record_web/effects.{add_item, entry_action, load_shelf, logout} +import at_record_web/effects.{ + add_item, discogs_search, entry_action, load_shelf, load_timeline, logout, + oauth_login, rate, regrade, +} import at_record_web/model.{ - type Model, Form, LoggedIn, LoggedOut, Model, blank_form, + type Model, Form, LoggedIn, LoggedOut, Model, Record, blank_form, } import at_record_web/msg.{ - type Msg, EntryAction, FormArtist, FormFormat, FormStatus, FormTitle, FormYear, - GotAction, GotAdd, GotLogout, GotShelf, HandleChanged, Logout, SetView, - SubmitAdd, + type Msg, DiscogsLoadMore, DiscogsQuery, DiscogsSearch, DiscogsVinylOnly, + EntryAction, FormArtist, FormFormat, FormStatus, FormTitle, FormYear, + GotAction, GotAdd, GotDiscogs, GotLogout, GotShelf, GotTimeline, HandleChanged, + Logout, OnRouteChange, Rate, Regrade, SetView, StartLogin, SubmitAdd, + ToggleEdit, ToggleEntry, UseDiscogs, } +import gleam/bool +import gleam/int +import gleam/list import gleam/option.{None, Some} import gleam/string import lustre/effect.{type Effect} @@ -14,11 +22,40 @@ import rsvp pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { + // Entering a record opens its detail and loads the event log; leaving it + // clears the selection so the crate/add views start fresh. + OnRouteChange(Record(entry_id)) -> #( + Model( + ..model, + route: Record(entry_id), + selected: Some(entry_id), + editing: False, + timeline: [], + busy: True, + ), + load_timeline(entry_id), + ) + OnRouteChange(route) -> #( + Model(..model, route:, selected: None, editing: False, timeline: []), + effect.none(), + ) + + ToggleEdit -> #( + Model(..model, editing: bool.negate(model.editing)), + effect.none(), + ) + HandleChanged(value) -> #( Model(..model, login_handle: value), effect.none(), ) + StartLogin -> + case string.trim(model.login_handle) { + "" -> #(model, effect.none()) + handle -> #(Model(..model, notice: None), oauth_login(handle)) + } + Logout -> #( Model(..model, auth: LoggedOut, items: [], notice: None), logout(), @@ -103,8 +140,119 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Model(..model, busy: True), entry_action(entry_id, action), ) - GotAction(Ok(Nil)) -> #(Model(..model, busy: True), load_shelf(model.view)) + + ToggleEntry(entry_id) -> + case model.selected { + Some(open) if open == entry_id -> #( + Model(..model, selected: None, timeline: []), + effect.none(), + ) + _ -> #( + Model(..model, selected: Some(entry_id), busy: True, timeline: []), + load_timeline(entry_id), + ) + } + GotTimeline(Ok(events)) -> #( + Model(..model, timeline: events, busy: False), + effect.none(), + ) + GotTimeline(Error(_)) -> #( + Model( + ..model, + busy: False, + notice: Some("Couldn't load the history for that entry."), + ), + effect.none(), + ) + + Regrade(_, "") -> #(model, effect.none()) + Regrade(entry_id, grade) -> #( + Model(..model, busy: True), + regrade(entry_id, grade), + ) + Rate(entry_id, rating) -> + case int.parse(rating) { + Ok(value) -> #(Model(..model, busy: True), rate(entry_id, value)) + Error(_) -> #(model, effect.none()) + } + + GotAction(Ok(Nil)) -> #(Model(..model, busy: True), after_write(model)) GotAction(Error(e)) -> write_error(model, e, "Could not update the entry.") + + DiscogsQuery(value) -> #( + Model(..model, discogs_query: value), + effect.none(), + ) + DiscogsSearch -> run_discogs_search(model, 1) + DiscogsVinylOnly(value) -> + run_discogs_search(Model(..model, discogs_vinyl_only: value), 1) + DiscogsLoadMore -> + case model.discogs_searching, model.discogs_page < model.discogs_pages { + False, True -> run_discogs_search(model, model.discogs_page + 1) + _, _ -> #(model, effect.none()) + } + GotDiscogs(Ok(found)) -> { + // Page 1 is a fresh search (replace); later pages are scroll loads (append). + let results = case found.page { + 1 -> found.results + _ -> list.append(model.discogs_results, found.results) + } + #( + Model( + ..model, + discogs_results: results, + discogs_page: found.page, + discogs_pages: found.pages, + discogs_searching: False, + ), + effect.none(), + ) + } + GotDiscogs(Error(_)) -> #( + Model( + ..model, + discogs_searching: False, + notice: Some("Discogs search failed."), + ), + effect.none(), + ) + UseDiscogs(result) -> #( + Model( + ..model, + discogs_results: [], + form: Form( + ..model.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, ""), + discogs_id: int.to_string(result.discogs_id), + ), + ), + effect.none(), + ) + } +} + +/// Search Discogs for a page with the current query + vinyl filter, or no-op on +/// an empty query. +fn run_discogs_search(model: Model, page: Int) -> #(Model, Effect(Msg)) { + case string.trim(model.discogs_query) { + "" -> #(model, effect.none()) + query -> #( + Model(..model, discogs_searching: True, notice: None), + discogs_search(query, model.discogs_vinyl_only, page), + ) + } +} + +/// After an event lands, refresh the shelf and the open timeline (if any). +fn after_write(model: Model) -> Effect(Msg) { + case model.selected { + Some(entry_id) -> + effect.batch([load_shelf(model.view), load_timeline(entry_id)]) + None -> load_shelf(model.view) } } diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index 468f10a..94ca16a 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -1,204 +1,89 @@ +//// Root view: the phone shell (status bar + app bar) and route dispatch to the +//// page modules. + import at_record_web/model.{ - type Entry, type Form, type Model, LoggedIn, LoggedOut, -} -import at_record_web/msg.{ - type Msg, EntryAction, FormArtist, FormFormat, FormStatus, FormTitle, FormYear, - HandleChanged, Logout, SetView, SubmitAdd, + type Entry, type Model, type Route, Add, Crate, LoggedIn, LoggedOut, Record, } -import gleam/int +import at_record_web/msg.{type Msg, Logout} +import at_record_web/pages/add +import at_record_web/pages/crate +import at_record_web/pages/login +import at_record_web/pages/record +import at_record_web/ui/components as c import gleam/list import gleam/option.{type Option, None, Some} -import gleam/string -import gleam/uri 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.main([attr.class("wrap")], [ - html.h1([], [text("at-record")]), - html.p([attr.class("tagline")], [text("your crate, stored on your PDS")]), + html.div([attr.class("app")], [ + c.status_bar(), notice_view(model.notice), case model.auth, model.busy { - LoggedOut, True -> restoring_view() - LoggedOut, False -> login_view(model) - LoggedIn(handle), _ -> shelf_view(model, handle) + LoggedOut, True -> login.restoring() + LoggedOut, False -> login.view(model) + LoggedIn(handle), _ -> authed_view(model, handle) }, ]) } -fn restoring_view() -> Element(Msg) { - html.section([attr.class("card")], [ - html.p([attr.class("hint")], [text("Restoring your session…")]), - ]) +fn authed_view(model: Model, handle: String) -> Element(Msg) { + html.div([], [app_bar(model.route, handle), page(model)]) } -fn notice_view(notice: Option(String)) -> Element(Msg) { - case notice { - Some(message) -> - html.p([attr.class("notice"), attr.role("alert")], [text(message)]) - None -> element.none() +fn page(model: Model) -> Element(Msg) { + case model.route { + Crate -> crate.view(model) + Add -> add.view(model) + Record(entry_id) -> + case find_entry(model.items, entry_id) { + Some(entry) -> record.view(model, entry) + None -> crate.view(model) + } } } -fn login_view(model: Model) -> Element(Msg) { - html.section([attr.class("card")], [ - html.h2([], [text("Sign in")]), - field( - "Handle or DID", - "mokkenstorm.dev", - model.login_handle, - HandleChanged, - "text", - ), - login_action(model.login_handle), - html.p([attr.class("hint")], [ - text("You'll be sent to your PDS to authorize at-record via OAuth."), - ]), - ]) -} - -fn login_action(handle: String) -> Element(Msg) { - case string.trim(handle) { - "" -> - html.button([attr.class("primary"), attr.disabled(True)], [ - text("Log in with atproto"), - ]) - trimmed -> - html.a( - [ - attr.class("primary"), - attr.href("/api/oauth/login?handle=" <> uri.percent_encode(trimmed)), - ], - [text("Log in with atproto")], - ) +fn find_entry(items: List(Entry), entry_id: String) -> Option(Entry) { + case list.find(items, fn(entry) { entry.entry_id == entry_id }) { + Ok(entry) -> Some(entry) + Error(_) -> None } } -fn shelf_view(model: Model, handle: String) -> Element(Msg) { - html.div([], [ - html.div([attr.class("topbar")], [ - html.span([], [text("@" <> handle)]), - html.button([event.on_click(Logout)], [text("Sign out")]), - ]), - add_form_view(model.form, model.busy), - view_tabs(model.view), - items_view(model.items, model.busy), - ]) -} - -fn view_tabs(current: String) -> Element(Msg) { - let tab = fn(value: String, label: String) { - let class = case value == current { - True -> "tab active" - False -> "tab" - } - html.button([attr.class(class), event.on_click(SetView(value))], [ - text(label), - ]) +fn app_bar(route: Route, handle: String) -> Element(Msg) { + case route { + Crate -> + c.app_bar(c.brand("AT·RECORD"), element.none(), crate_actions(handle)) + Add -> back_bar("ADD RECORD") + Record(_) -> back_bar("RECORD") } - html.div([attr.class("tabs")], [ - tab("owned", "Crate"), - tab("wanted", "Wishlist"), - tab("history", "History"), - ]) } -fn add_form_view(form: Form, busy: Bool) -> Element(Msg) { - html.section([attr.class("card")], [ - html.h2([], [text("Add a record")]), - field("Title", "Spiderland", form.title, FormTitle, "text"), - field("Artist", "Slint", form.artist, FormArtist, "text"), - field("Format", "LP", form.format, FormFormat, "text"), - field("Year", "1991", form.year, FormYear, "number"), - html.label([], [ - html.span([], [text("Status")]), - html.select([event.on_change(FormStatus)], [ - status_option("owned", form.status), - status_option("wanted", form.status), - ]), - ]), - html.button( - [attr.class("primary"), event.on_click(SubmitAdd), attr.disabled(busy)], - [text("Add to crate")], - ), - ]) -} - -fn status_option(value: String, selected: String) -> Element(Msg) { - html.option([attr.value(value), attr.selected(value == selected)], value) -} - -fn items_view(items: List(Entry), busy: Bool) -> Element(Msg) { - case items { - [] -> - html.p([attr.class("empty")], [ - text("No records yet. Add your first above."), - ]) - _ -> - html.ul( - [attr.class("shelf")], - list.map(items, fn(item) { item_view(item, busy) }), - ) - } +fn back_bar(title: String) -> Element(Msg) { + c.app_bar(c.icon_link("←", "/"), c.bar_title(title), c.icon_disabled("⋯")) } -fn item_view(entry: Entry, busy: Bool) -> Element(Msg) { - let snap = entry.snapshot - let meta = - [Some(entry.status), snap.format, option.map(snap.year, int.to_string)] - |> option.values - |> string.join(" · ") - html.li([attr.class("item")], [ - html.div([attr.class("item-main")], [ - html.strong([], [text(snap.artist_display)]), - text(" — " <> snap.title), - html.div([attr.class("item-meta")], [text(meta)]), - ]), - html.div([attr.class("item-actions")], item_actions(entry, busy)), +fn crate_actions(handle: String) -> Element(Msg) { + html.div([attr.class("app-bar__right")], [ + c.icon_disabled("⦀"), + avatar_signout(handle), ]) } -fn item_actions(entry: Entry, busy: Bool) -> List(Element(Msg)) { - let act = fn(label: String, action: String, danger: Bool) { - let class = case danger { - True -> "danger" - False -> "" - } - html.button( - [ - attr.class(class), - attr.disabled(busy), - event.on_click(EntryAction(entry.entry_id, action)), - ], - [text(label)], - ) - } - case entry.status { - "owned" -> [act("Sold", "sold", False), act("Remove", "dropped", True)] - "wanted" -> [ - act("Got it", "acquired", False), - act("Remove", "dropped", True), - ] - _ -> [act("Re-add", "acquired", False)] - } +/// The avatar doubles as the sign-out control (tap to log out). +fn avatar_signout(handle: String) -> Element(Msg) { + html.button( + [attr.class("avatar"), attr.title("Sign out"), event.on_click(Logout)], + [text(c.cover_initial(handle))], + ) } -fn field( - label: String, - placeholder: String, - value: String, - on_input: fn(String) -> Msg, - input_type: String, -) -> Element(Msg) { - html.label([], [ - html.span([], [text(label)]), - html.input([ - attr.type_(input_type), - attr.placeholder(placeholder), - attr.value(value), - event.on_input(on_input), - ]), - ]) +fn notice_view(notice: Option(String)) -> Element(Msg) { + case notice { + Some(message) -> + html.p([attr.class("notice"), attr.role("alert")], [text(message)]) + None -> element.none() + } } diff --git a/web/test/at_record_web_test.gleam b/web/test/at_record_web_test.gleam index dd56127..8d955a3 100644 --- a/web/test/at_record_web_test.gleam +++ b/web/test/at_record_web_test.gleam @@ -1,5 +1,5 @@ import at_record_web/model.{ - type Model, Form, LoggedIn, LoggedOut, Model, blank_form, + type Model, Crate, Form, LoggedIn, LoggedOut, Model, blank_form, } import at_record_web/msg.{ FormTitle, GotLogout, GotShelf, HandleChanged, Logout, SubmitAdd, @@ -17,12 +17,22 @@ pub fn main() -> Nil { fn base() -> Model { Model( auth: LoggedOut, + route: Crate, login_handle: "", view: "owned", items: [], form: blank_form(), notice: None, busy: False, + selected: None, + editing: False, + timeline: [], + discogs_query: "", + discogs_results: [], + discogs_searching: False, + discogs_vinyl_only: True, + discogs_page: 1, + discogs_pages: 0, ) }