fix: address static assets code review findings
- Remove dead CSS color property in .tab-mobile - Fix design token violations (text-red-500, fallback colors) - Add disconnectedCallback to chip-scroll, image-upload, searchable-select - Replace innerHTML with safe DOM APIs in world-map screen reader table - Add credentials: same-origin to WebAuthn fetch calls - Move page-specific scripts (donut-chart, location) out of base.html - Add client-side image resizing (1920px max dimension) - Resize app-icon-512.png from 2048x2048 to 512x512 - Document Datastar unsafe-eval CSP requirement - Add static asset serving tests (16 routes) - Add e2e tests for world-map, donut-chart, chip-scroll presence - Add cache-busting query params to all static asset URLs
This commit is contained in:
parent
920931ba17
commit
5820c162f6
21 changed files with 437 additions and 125 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 44 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
"<table><caption>Coffee origins by country</caption><thead><tr><th>Country</th><th>Count</th></tr></thead><tbody>" +
|
||||
entries
|
||||
.map(
|
||||
([code, count]) =>
|
||||
`<tr><td>${code.toUpperCase()}</td><td>${count}</td></tr>`,
|
||||
)
|
||||
.join("") +
|
||||
"</tbody></table>";
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,14 +28,20 @@
|
|||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<title>{% block title %}Brewlog{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/css/styles.css?v={{ version_info.commit }}"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
id="favicon"
|
||||
type="image/svg+xml"
|
||||
href="/static/favicon-light.svg"
|
||||
href="/static/favicon-light.svg?v={{ version_info.commit }}"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="/static/app-icon-192.png?v={{ version_info.commit }}"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/static/app-icon-192.png" />
|
||||
<link rel="manifest" href="/static/site.webmanifest" />
|
||||
<meta name="apple-mobile-web-app-title" content="Brewlog" />
|
||||
<meta name="theme-color" content="#c2410c" />
|
||||
|
|
@ -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 }}";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -55,14 +62,30 @@
|
|||
type="module"
|
||||
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
|
||||
></script>
|
||||
<script defer src="/static/js/location.js"></script>
|
||||
<script defer src="/static/js/image-utils.js"></script>
|
||||
<script defer src="/static/js/components/photo-capture.js"></script>
|
||||
<script defer src="/static/js/components/searchable-select.js"></script>
|
||||
<script defer src="/static/js/components/chip-scroll.js"></script>
|
||||
<script defer src="/static/js/components/world-map.js"></script>
|
||||
<script defer src="/static/js/components/donut-chart.js"></script>
|
||||
<script defer src="/static/js/components/image-upload.js"></script>
|
||||
<script
|
||||
defer
|
||||
src="/static/js/image-utils.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
<script
|
||||
defer
|
||||
src="/static/js/components/photo-capture.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
<script
|
||||
defer
|
||||
src="/static/js/components/searchable-select.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
<script
|
||||
defer
|
||||
src="/static/js/components/chip-scroll.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
<script
|
||||
defer
|
||||
src="/static/js/components/world-map.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
<script
|
||||
defer
|
||||
src="/static/js/components/image-upload.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
{% block head %}{% endblock %}
|
||||
<script>
|
||||
const showToast = (msg, ms = 3000) => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
{% import "partials/detail_cards.html" as detail_cards %}
|
||||
{% import "partials/forms/quick_notes.html" as quick_notes %}
|
||||
{% block title %}Brewlog · Add{% endblock %}
|
||||
{% block head %}
|
||||
<script
|
||||
defer
|
||||
src="/static/js/location.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · Admin{% endblock %}
|
||||
{% block head %}
|
||||
<script defer src="/static/js/webauthn.js"></script>
|
||||
<script
|
||||
defer
|
||||
src="/static/js/webauthn.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<header class="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@
|
|||
{% block title %}
|
||||
Brewlog · Check In
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
<script
|
||||
defer
|
||||
src="/static/js/location.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section
|
||||
id="checkin-root"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · Login{% endblock %}
|
||||
{% block head %}
|
||||
<script defer src="/static/js/webauthn.js"></script>
|
||||
<script
|
||||
defer
|
||||
src="/static/js/webauthn.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-md">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · Register{% endblock %}
|
||||
{% block head %}
|
||||
<script defer src="/static/js/webauthn.js"></script>
|
||||
<script
|
||||
defer
|
||||
src="/static/js/webauthn.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-md">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
{% endblock %}
|
||||
{% block head %}
|
||||
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||
<script
|
||||
defer
|
||||
src="/static/js/components/donut-chart.js?v={{ version_info.commit }}"
|
||||
></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<header class="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("<chip-scroll"),
|
||||
"Homepage should contain chip-scroll component when data exists"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
98
tests/server/static_assets.rs
Normal file
98
tests/server/static_assets.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use crate::helpers::spawn_app;
|
||||
|
||||
macro_rules! define_static_asset_test {
|
||||
($name:ident, $path:expr, $content_type:expr) => {
|
||||
#[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"
|
||||
);
|
||||
|
|
@ -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("<world-map"),
|
||||
"Roasts tab should contain world-map component"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_page_contains_donut_chart_with_brew_data() {
|
||||
let app = spawn_app_with_auth().await;
|
||||
let client = Client::new();
|
||||
|
||||
let _brew = create_default_brew(&app).await;
|
||||
|
||||
// Populate the cache
|
||||
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);
|
||||
|
||||
let response = client
|
||||
.get(app.page_url("/stats"))
|
||||
.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("<donut-chart"),
|
||||
"Stats page should contain donut-chart component when brew data exists"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_page_loads_after_recompute() {
|
||||
let app = spawn_app_with_auth().await;
|
||||
|
|
|
|||
Loading…
Reference in a new issue