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,
)
}