diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 589783f..c8c1076 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -49,6 +49,7 @@ pub fn app_router(state: AppState) -> axum::Router { axum::http::header::REFERRER_POLICY, HeaderValue::from_static("strict-origin-when-cross-origin"), )) + // Datastar v1 evaluates expressions via Function(), requiring 'unsafe-eval'. .layer(SetResponseHeaderLayer::overriding( axum::http::header::CONTENT_SECURITY_POLICY, HeaderValue::from_static( diff --git a/static/app-icon-512.png b/static/app-icon-512.png index 63a2981..33080e8 100644 Binary files a/static/app-icon-512.png and b/static/app-icon-512.png differ diff --git a/static/css/input.css b/static/css/input.css index 816c05c..121cd6b 100644 --- a/static/css/input.css +++ b/static/css/input.css @@ -465,7 +465,6 @@ input.input-field[type="number"] { cursor: pointer; border: none; background: none; - color: var(--accent); transition: background-color 150ms ease, color 150ms ease; diff --git a/static/js/components/chip-scroll.js b/static/js/components/chip-scroll.js index 3e9b4e7..824cf01 100644 --- a/static/js/components/chip-scroll.js +++ b/static/js/components/chip-scroll.js @@ -5,14 +5,22 @@ customElements.define( this._setup(); } + disconnectedCallback() { + this._observer?.disconnect(); + this._resizeObserver?.disconnect(); + this._scroller?.removeEventListener("scroll", this._scrollHandler); + } + _setup() { - if (this._observer) this._observer.disconnect(); + this.disconnectedCallback(); const scroller = this.querySelector("[data-chip-scroll]"); const btnL = this.querySelector("[data-scroll-left]"); const btnR = this.querySelector("[data-scroll-right]"); if (!scroller || !btnL || !btnR) return; + this._scroller = scroller; + const update = () => { if (window.matchMedia("(max-width: 767px)").matches) { btnL.style.display = "none"; @@ -26,8 +34,10 @@ customElements.define( : "none"; }; + this._scrollHandler = update; scroller.addEventListener("scroll", update, { passive: true }); - new ResizeObserver(update).observe(scroller); + this._resizeObserver = new ResizeObserver(update); + this._resizeObserver.observe(scroller); this._observer = new MutationObserver(update); this._observer.observe(scroller, { childList: true }); diff --git a/static/js/components/donut-chart.js b/static/js/components/donut-chart.js index 0e5ad14..e861763 100644 --- a/static/js/components/donut-chart.js +++ b/static/js/components/donut-chart.js @@ -65,7 +65,7 @@ class DonutChart extends HTMLElement { const rgb = getComputedStyle(document.documentElement) .getPropertyValue("--highlight-rgb") - .trim() || "185, 28, 28"; + .trim() || "194, 65, 12"; const colorFor = (count) => { const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2); return `rgba(${rgb}, ${alpha})`; diff --git a/static/js/components/image-upload.js b/static/js/components/image-upload.js index 0681074..af6bef4 100644 --- a/static/js/components/image-upload.js +++ b/static/js/components/image-upload.js @@ -2,39 +2,66 @@ customElements.define( "image-upload", class extends HTMLElement { connectedCallback() { + this._ac = new AbortController(); + const { signal } = this._ac; + const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.hidden = true; this.appendChild(input); - this.addEventListener("click", (e) => { - if (e.target !== input) input.click(); - }); + this.addEventListener( + "click", + (e) => { + if (e.target !== input) input.click(); + }, + { signal }, + ); - this.addEventListener("dragover", (e) => { - e.preventDefault(); - this.classList.add("border-accent"); - }); + this.addEventListener( + "dragover", + (e) => { + e.preventDefault(); + this.classList.add("border-accent"); + }, + { signal }, + ); - this.addEventListener("dragleave", () => { - this.classList.remove("border-accent"); - }); + this.addEventListener( + "dragleave", + () => { + this.classList.remove("border-accent"); + }, + { signal }, + ); - this.addEventListener("drop", (e) => { - e.preventDefault(); - this.classList.remove("border-accent"); - const file = e.dataTransfer?.files[0]; - if (file && file.type.startsWith("image/")) { - this._handleFile(file); - } - }); + this.addEventListener( + "drop", + (e) => { + e.preventDefault(); + this.classList.remove("border-accent"); + const file = e.dataTransfer?.files[0]; + if (file && file.type.startsWith("image/")) { + this._handleFile(file); + } + }, + { signal }, + ); - input.addEventListener("change", () => { - const file = input.files[0]; - if (file) this._handleFile(file); - input.value = ""; - }); + input.addEventListener( + "change", + () => { + const file = input.files[0]; + if (file) this._handleFile(file); + input.value = ""; + }, + { signal }, + ); + } + + disconnectedCallback() { + this._ac?.abort(); } async _handleFile(file) { @@ -113,7 +140,7 @@ customElements.define( } catch { this.innerHTML = originalContent; const errEl = document.createElement("p"); - errEl.className = "text-xs text-red-500 mt-1"; + errEl.className = "text-xs text-error mt-1"; errEl.textContent = "Upload failed. Try again."; this.parentElement.appendChild(errEl); setTimeout(() => errEl.remove(), 3000); diff --git a/static/js/components/searchable-select.js b/static/js/components/searchable-select.js index 2d16720..42b1c84 100644 --- a/static/js/components/searchable-select.js +++ b/static/js/components/searchable-select.js @@ -5,9 +5,16 @@ customElements.define( requestAnimationFrame(() => this._setup()); } + disconnectedCallback() { + this._ac?.abort(); + this._initialized = false; + } + _setup() { if (this._initialized) return; this._initialized = true; + this._ac = new AbortController(); + const { signal } = this._ac; const name = this.getAttribute("name"); const placeholder = @@ -66,7 +73,7 @@ customElements.define( (e) => { if (!(e instanceof CustomEvent)) e.stopImmediatePropagation(); }, - true, + { capture: true, signal }, ); this.textContent = ""; @@ -93,70 +100,83 @@ customElements.define( ); }; - search.addEventListener("input", () => { - const q = search.value.toLowerCase(); - options.classList.toggle("hidden", !q); - options.querySelector(".ss-active")?.classList.remove("ss-active"); - buttons.forEach((btn) => { - btn.style.display = btn.textContent.toLowerCase().includes(q) - ? "" - : "none"; - }); - updateExpanded(); - }); - - search.addEventListener("keydown", (e) => { - const visible = buttons.filter( - (b) => - b.style.display !== "none" && !options.classList.contains("hidden"), - ); - if (!visible.length) return; - - const active = options.querySelector(".ss-active"); - let idx = active ? visible.indexOf(active) : -1; - - if (e.key === "ArrowDown") { - e.preventDefault(); - if (active) active.classList.remove("ss-active"); - idx = (idx + 1) % visible.length; - visible[idx].classList.add("ss-active"); - visible[idx].scrollIntoView({ block: "nearest" }); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - if (active) active.classList.remove("ss-active"); - idx = idx <= 0 ? visible.length - 1 : idx - 1; - visible[idx].classList.add("ss-active"); - visible[idx].scrollIntoView({ block: "nearest" }); - } else if (e.key === "Enter" && active) { - e.preventDefault(); - active.click(); - } else if (e.key === "Escape") { - options.classList.add("hidden"); + search.addEventListener( + "input", + () => { + const q = search.value.toLowerCase(); + options.classList.toggle("hidden", !q); + options.querySelector(".ss-active")?.classList.remove("ss-active"); + buttons.forEach((btn) => { + btn.style.display = btn.textContent.toLowerCase().includes(q) + ? "" + : "none"; + }); updateExpanded(); - } - }); + }, + { signal }, + ); - options.addEventListener("click", (e) => { - const btn = e.target.closest("button"); - if (!btn || !options.contains(btn)) return; + search.addEventListener( + "keydown", + (e) => { + const visible = buttons.filter( + (b) => + b.style.display !== "none" && + !options.classList.contains("hidden"), + ); + if (!visible.length) return; - hidden.value = btn.value; - display.textContent = btn.dataset.display; - searchWrap.classList.add("hidden"); - selectedWrap.classList.remove("hidden"); - updateExpanded(); + const active = options.querySelector(".ss-active"); + let idx = active ? visible.indexOf(active) : -1; - this.dispatchEvent( - new CustomEvent("change", { - detail: { - value: btn.value, - display: btn.dataset.display, - data: { ...btn.dataset }, - }, - bubbles: true, - }), - ); - }); + if (e.key === "ArrowDown") { + e.preventDefault(); + if (active) active.classList.remove("ss-active"); + idx = (idx + 1) % visible.length; + visible[idx].classList.add("ss-active"); + visible[idx].scrollIntoView({ block: "nearest" }); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (active) active.classList.remove("ss-active"); + idx = idx <= 0 ? visible.length - 1 : idx - 1; + visible[idx].classList.add("ss-active"); + visible[idx].scrollIntoView({ block: "nearest" }); + } else if (e.key === "Enter" && active) { + e.preventDefault(); + active.click(); + } else if (e.key === "Escape") { + options.classList.add("hidden"); + updateExpanded(); + } + }, + { signal }, + ); + + options.addEventListener( + "click", + (e) => { + const btn = e.target.closest("button"); + if (!btn || !options.contains(btn)) return; + + hidden.value = btn.value; + display.textContent = btn.dataset.display; + searchWrap.classList.add("hidden"); + selectedWrap.classList.remove("hidden"); + updateExpanded(); + + this.dispatchEvent( + new CustomEvent("change", { + detail: { + value: btn.value, + display: btn.dataset.display, + data: { ...btn.dataset }, + }, + bubbles: true, + }), + ); + }, + { signal }, + ); const doClear = () => { hidden.value = ""; @@ -171,7 +191,7 @@ customElements.define( this.dispatchEvent(new CustomEvent("clear", { bubbles: true })); }; - selectedWrap.addEventListener("click", doClear); + selectedWrap.addEventListener("click", doClear, { signal }); } }, ); diff --git a/static/js/components/world-map.js b/static/js/components/world-map.js index 084abed..1d7460b 100644 --- a/static/js/components/world-map.js +++ b/static/js/components/world-map.js @@ -84,15 +84,24 @@ customElements.define( const entries = raw.split(",").map((e) => e.split(":")); const sr = document.createElement("div"); sr.className = "sr-only"; - sr.innerHTML = - "" + - entries - .map( - ([code, count]) => - ``, - ) - .join("") + - "
Coffee origins by country
CountryCount
${code.toUpperCase()}${count}
"; + const table = document.createElement("table"); + const caption = table.createCaption(); + caption.textContent = "Coffee origins by country"; + const thead = table.createTHead(); + const headRow = thead.insertRow(); + const th1 = document.createElement("th"); + th1.textContent = "Country"; + const th2 = document.createElement("th"); + th2.textContent = "Count"; + headRow.appendChild(th1); + headRow.appendChild(th2); + const tbody = table.createTBody(); + for (const [code, count] of entries) { + const row = tbody.insertRow(); + row.insertCell().textContent = code.toUpperCase(); + row.insertCell().textContent = count; + } + sr.appendChild(table); this.appendChild(sr); } @@ -110,9 +119,9 @@ customElements.define( const styles = getComputedStyle(document.documentElement); const rgb = - styles.getPropertyValue("--highlight-rgb").trim() || "185, 28, 28"; + styles.getPropertyValue("--highlight-rgb").trim() || "194, 65, 12"; const borderColor = - styles.getPropertyValue("--text-muted").trim() || "#9ca3af"; + styles.getPropertyValue("--text-muted").trim() || "#78716c"; const surfaceAlt = styles.getPropertyValue("--surface-alt").trim() || "#f5f5f4"; const borderMuted = diff --git a/static/js/image-utils.js b/static/js/image-utils.js index 4e9919e..f52e6ed 100644 --- a/static/js/image-utils.js +++ b/static/js/image-utils.js @@ -1,11 +1,19 @@ /** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas. - * Uses createImageBitmap which correctly applies EXIF orientation (e.g. iPhone photos). */ + * Uses createImageBitmap which correctly applies EXIF orientation (e.g. iPhone photos). + * Caps the longest side to 1920px to avoid exceeding the request body limit. */ const imageToJpegDataUrl = async (file) => { const bitmap = await createImageBitmap(file); + const maxDim = 1920; + let { width, height } = bitmap; + if (width > maxDim || height > maxDim) { + const scale = maxDim / Math.max(width, height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } const canvas = document.createElement("canvas"); - canvas.width = bitmap.width; - canvas.height = bitmap.height; - canvas.getContext("2d").drawImage(bitmap, 0, 0); + canvas.width = width; + canvas.height = height; + canvas.getContext("2d").drawImage(bitmap, 0, 0, width, height); bitmap.close(); return canvas.toDataURL("image/jpeg", 0.92); }; diff --git a/static/js/webauthn.js b/static/js/webauthn.js index d897270..b631691 100644 --- a/static/js/webauthn.js +++ b/static/js/webauthn.js @@ -82,6 +82,7 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => { // 1. Get challenge from server const startResponse = await fetch("/api/v1/webauthn/register/start", { method: "POST", + credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, @@ -107,6 +108,7 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => { // 3. Send credential to server const finishResponse = await fetch("/api/v1/webauthn/register/finish", { method: "POST", + credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge_id, @@ -128,7 +130,7 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => { const startPasskeyAuthentication = async (queryParams) => { // 1. Get challenge from server const url = `/api/v1/webauthn/auth/start${queryParams || ""}`; - const startResponse = await fetch(url); + const startResponse = await fetch(url, { credentials: "same-origin" }); if (!startResponse.ok) { const status = startResponse.status; @@ -146,6 +148,7 @@ const startPasskeyAuthentication = async (queryParams) => { // 3. Send assertion to server const finishResponse = await fetch("/api/v1/webauthn/auth/finish", { method: "POST", + credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge_id, @@ -165,6 +168,7 @@ const addPasskey = async (name) => { // 1. Get challenge from server const startResponse = await fetch("/api/v1/webauthn/passkey/start", { method: "POST", + credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); @@ -184,6 +188,7 @@ const addPasskey = async (name) => { // 3. Send credential to server const finishResponse = await fetch("/api/v1/webauthn/passkey/finish", { method: "POST", + credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge_id, diff --git a/templates/base.html b/templates/base.html index f5affc5..5c56d57 100644 --- a/templates/base.html +++ b/templates/base.html @@ -28,14 +28,20 @@ /> {% block title %}Brewlog{% endblock %} - + + - @@ -47,7 +53,8 @@ (!stored && matchMedia("(prefers-color-scheme: dark)").matches); if (isDark) { document.documentElement.setAttribute("data-theme", "dark"); - document.getElementById("favicon").href = "/static/favicon-dark.svg"; + document.getElementById("favicon").href = + "/static/favicon-dark.svg?v={{ version_info.commit }}"; } })(); @@ -55,14 +62,30 @@ type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js" > - - - - - - - - + + + + + + {% block head %}{% endblock %} +{% endblock %} {% block content %}
+ {% endblock %} {% block content %}
diff --git a/templates/pages/checkin.html b/templates/pages/checkin.html index 4a388f1..e7a7bc2 100644 --- a/templates/pages/checkin.html +++ b/templates/pages/checkin.html @@ -7,6 +7,12 @@ {% block title %} Brewlog · Check In {% endblock %} +{% block head %} + +{% endblock %} {% block content %}
+ {% endblock %} {% block content %}
diff --git a/templates/pages/register.html b/templates/pages/register.html index 13173f9..ff19137 100644 --- a/templates/pages/register.html +++ b/templates/pages/register.html @@ -1,7 +1,10 @@ {% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Register{% endblock %} {% block head %} - + {% endblock %} {% block content %}
diff --git a/templates/pages/stats.html b/templates/pages/stats.html index 31cc66c..db2fa1d 100644 --- a/templates/pages/stats.html +++ b/templates/pages/stats.html @@ -7,6 +7,10 @@ {% endblock %} {% block head %} + {% endblock %} {% block content %}
diff --git a/tests/server/main.rs b/tests/server/main.rs index 838403b..d8fab5f 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -16,6 +16,7 @@ pub mod pages; pub mod roasters_api; pub mod roasts_api; pub mod scan_api; +pub mod static_assets; pub mod stats_api; pub mod test_macros; pub mod timeline; diff --git a/tests/server/pages.rs b/tests/server/pages.rs index d3bc935..83d85e2 100644 --- a/tests/server/pages.rs +++ b/tests/server/pages.rs @@ -1,8 +1,8 @@ use reqwest::redirect::Policy; use crate::helpers::{ - assert_full_page, create_default_bag, create_default_roast, create_default_roaster, - create_session, spawn_app, spawn_app_with_auth, + assert_full_page, create_default_bag, create_default_brew, create_default_roast, + create_default_roaster, create_session, spawn_app, spawn_app_with_auth, }; #[tokio::test] @@ -284,3 +284,25 @@ async fn scan_redirect_returns_permanent_redirect() { .and_then(|v| v.to_str().ok()); assert_eq!(location, Some("/")); } + +#[tokio::test] +async fn homepage_contains_chip_scroll_with_data() { + let app = spawn_app_with_auth().await; + + let _brew = create_default_brew(&app).await; + + let client = reqwest::Client::new(); + let response = client + .get(app.page_url("/")) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 200); + + let body = response.text().await.expect("Failed to read body"); + assert!( + body.contains(" { + #[tokio::test] + async fn $name() { + let app = spawn_app().await; + let client = reqwest::Client::new(); + + let response = client + .get(app.page_url($path)) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 200); + assert_eq!( + response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some($content_type), + "Wrong content-type for {}", + $path + ); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()), + Some("public, max-age=604800"), + "Wrong cache-control for {}", + $path + ); + } + }; +} + +define_static_asset_test!( + styles_css, + "/static/css/styles.css", + "text/css; charset=utf-8" +); +define_static_asset_test!( + webauthn_js, + "/static/js/webauthn.js", + "application/javascript; charset=utf-8" +); +define_static_asset_test!( + location_js, + "/static/js/location.js", + "application/javascript; charset=utf-8" +); +define_static_asset_test!( + image_utils_js, + "/static/js/image-utils.js", + "application/javascript; charset=utf-8" +); +define_static_asset_test!( + photo_capture_js, + "/static/js/components/photo-capture.js", + "application/javascript; charset=utf-8" +); +define_static_asset_test!( + searchable_select_js, + "/static/js/components/searchable-select.js", + "application/javascript; charset=utf-8" +); +define_static_asset_test!( + chip_scroll_js, + "/static/js/components/chip-scroll.js", + "application/javascript; charset=utf-8" +); +define_static_asset_test!( + world_map_js, + "/static/js/components/world-map.js", + "application/javascript; charset=utf-8" +); +define_static_asset_test!( + donut_chart_js, + "/static/js/components/donut-chart.js", + "application/javascript; charset=utf-8" +); +define_static_asset_test!( + image_upload_js, + "/static/js/components/image-upload.js", + "application/javascript; charset=utf-8" +); +define_static_asset_test!(favicon_light, "/static/favicon-light.svg", "image/svg+xml"); +define_static_asset_test!(favicon_dark, "/static/favicon-dark.svg", "image/svg+xml"); +define_static_asset_test!(og_image, "/static/og-image.png", "image/png"); +define_static_asset_test!(app_icon_192, "/static/app-icon-192.png", "image/png"); +define_static_asset_test!(app_icon_512, "/static/app-icon-512.png", "image/png"); +define_static_asset_test!( + site_webmanifest, + "/static/site.webmanifest", + "application/manifest+json; charset=utf-8" +); diff --git a/tests/server/stats_api.rs b/tests/server/stats_api.rs index bc8a4c7..7a78c51 100644 --- a/tests/server/stats_api.rs +++ b/tests/server/stats_api.rs @@ -1,8 +1,9 @@ use reqwest::Client; use crate::helpers::{ - assert_datastar_headers_with_mode, assert_full_page, assert_html_fragment, create_default_cafe, - create_default_roast, create_default_roaster, spawn_app, spawn_app_with_auth, + assert_datastar_headers_with_mode, assert_full_page, assert_html_fragment, create_default_brew, + create_default_cafe, create_default_roast, create_default_roaster, spawn_app, + spawn_app_with_auth, }; #[tokio::test] @@ -167,6 +168,69 @@ async fn recompute_stats_reflects_created_data() { ); } +#[tokio::test] +async fn stats_page_contains_world_map_with_geo_data() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + let roaster = create_default_roaster(&app).await; + let _roast = create_default_roast(&app, roaster.id).await; + + // Populate the cache so the stats page has data + let recompute = client + .post(app.api_url("/stats/recompute")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .send() + .await + .expect("Failed to recompute"); + assert_eq!(recompute.status(), 200); + + // Fetch the roasts tab fragment which contains the world-map + let response = client + .get(app.page_url("/stats?type=roasts")) + .header("datastar-request", "true") + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 200); + let body = response.text().await.expect("Failed to read body"); + assert!( + body.contains("