+
No {entities} recorded yet.
{% else %}
@@ -797,10 +809,15 @@ List partials live in `templates/partials/lists/`. Each follows:
...
{% if items.is_empty() %}
-
No {entities} match your search.
- {% endif %} {% call table::pagination_header(items, navigator, "#{entity}-list") %} {% if
- items.has_next() %}
-
+
+ No {entities} match your search.
+
+ {% endif %} {% call table::pagination_header(items, navigator,
+ "#{entity}-list") %} {% if items.has_next() %}
+
{% endif %}
{% endif %}
@@ -834,14 +851,18 @@ Tables use the `responsive-table` CSS class (card layout on mobile, standard tab
```html
{{ brew.roast_name }}
- {{ brew.roaster_name }}
+
+ {{ brew.roaster_name }}
+
```
**Mobile** — separate `
` for each sub-field:
```html
- {{ brew.roaster_name }}
+
+ {{ brew.roaster_name }}
+
```
### Pagination & Infinite Scroll
diff --git a/static/css/input.css b/static/css/input.css
index 3e730a9..69da535 100644
--- a/static/css/input.css
+++ b/static/css/input.css
@@ -12,27 +12,27 @@
color-scheme: light;
/* Surfaces */
- --page: #fafaf9; /* stone-50 */
- --surface: #ffffff; /* white */
- --surface-alt: #f5f5f4; /* stone-100 */
+ --page: #fafaf9; /* stone-50 */
+ --surface: #ffffff; /* white */
+ --surface-alt: #f5f5f4; /* stone-100 */
/* Borders */
- --border: #e7e5e4; /* stone-200 */
- --border-muted: #f5f5f4; /* stone-100 */
+ --border: #e7e5e4; /* stone-200 */
+ --border-muted: #f5f5f4; /* stone-100 */
/* Accent */
- --accent: #c2410c; /* orange-700 */
- --accent-hover: #ea580c; /* orange-600 */
- --accent-subtle: #fff7ed; /* orange-50 */
+ --accent: #c2410c; /* orange-700 */
+ --accent-hover: #ea580c; /* orange-600 */
+ --accent-subtle: #fff7ed; /* orange-50 */
--accent-text: #ffffff;
/* Text */
- --text: #1c1917; /* stone-900 */
- --text-secondary: #57534e; /* stone-600 */
- --text-muted: #78716c; /* stone-500 */
+ --text: #1c1917; /* stone-900 */
+ --text-secondary: #57534e; /* stone-600 */
+ --text-muted: #78716c; /* stone-500 */
/* Data-viz highlight (RGB triplet for alpha usage in JS) */
- --highlight-rgb: 194, 65, 12; /* orange-700, matches --accent */
+ --highlight-rgb: 194, 65, 12; /* orange-700, matches --accent */
}
[data-theme="dark"] {
@@ -54,7 +54,7 @@
--text-secondary: #d6d3d1;
--text-muted: #a8a29e;
- --highlight-rgb: 234, 88, 12; /* orange-600, matches --accent */
+ --highlight-rgb: 234, 88, 12; /* orange-600, matches --accent */
}
/* ── Theme: map raw vars to Tailwind utilities ─────────────────── */
@@ -87,7 +87,13 @@
/* ── Typography ────────────────────────────────────────────────── */
body {
- font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-family:
+ "Inter",
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
}
a {
@@ -125,7 +131,9 @@ a {
font-family: inherit;
font-size: 0.875rem;
color: var(--text);
- transition: border-color 150ms ease, box-shadow 150ms ease;
+ transition:
+ border-color 150ms ease,
+ box-shadow 150ms ease;
}
input.input-field[type="number"]::-webkit-inner-spin-button,
@@ -155,7 +163,9 @@ input.input-field[type="number"] {
background-color: var(--surface);
color: var(--accent);
font-weight: 700;
- transition: background-color 150ms ease, color 150ms ease;
+ transition:
+ background-color 150ms ease,
+ color 150ms ease;
}
.btn-adjust:hover {
@@ -343,7 +353,10 @@ input.input-field[type="number"] {
cursor: pointer;
border: 1px solid var(--border);
background-color: var(--surface);
- transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
+ transition:
+ background-color 150ms ease,
+ color 150ms ease,
+ border-color 150ms ease;
color: var(--accent);
}
@@ -368,7 +381,9 @@ input.input-field[type="number"] {
border: none;
background: none;
color: var(--accent);
- transition: background-color 150ms ease, color 150ms ease;
+ transition:
+ background-color 150ms ease,
+ color 150ms ease;
color: var(--text-secondary);
}
@@ -452,7 +467,9 @@ input.input-field[type="number"] {
/* ── Data page ─────────────────────────────────────────────────── */
/* Hide duplicate search inside data page - search is in the tab bar */
-.data-page-content section:has(.responsive-table) > div:first-child:has(input[type="search"]) {
+.data-page-content
+ section:has(.responsive-table)
+ > div:first-child:has(input[type="search"]) {
display: none;
}
diff --git a/static/js/components/chip-scroll.js b/static/js/components/chip-scroll.js
index 118da4a..3e9b4e7 100644
--- a/static/js/components/chip-scroll.js
+++ b/static/js/components/chip-scroll.js
@@ -1,30 +1,38 @@
-customElements.define("chip-scroll", class extends HTMLElement {
- connectedCallback() { this._setup(); }
+customElements.define(
+ "chip-scroll",
+ class extends HTMLElement {
+ connectedCallback() {
+ this._setup();
+ }
- _setup() {
- if (this._observer) this._observer.disconnect();
+ _setup() {
+ if (this._observer) this._observer.disconnect();
- 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;
+ 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;
- const update = () => {
- if (window.matchMedia("(max-width: 767px)").matches) {
- btnL.style.display = "none";
- btnR.style.display = "none";
- return;
- }
- btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none";
- btnR.style.display = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1 ? "flex" : "none";
- };
+ const update = () => {
+ if (window.matchMedia("(max-width: 767px)").matches) {
+ btnL.style.display = "none";
+ btnR.style.display = "none";
+ return;
+ }
+ btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none";
+ btnR.style.display =
+ scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1
+ ? "flex"
+ : "none";
+ };
- scroller.addEventListener("scroll", update, { passive: true });
- new ResizeObserver(update).observe(scroller);
+ scroller.addEventListener("scroll", update, { passive: true });
+ new ResizeObserver(update).observe(scroller);
- this._observer = new MutationObserver(update);
- this._observer.observe(scroller, { childList: true });
+ this._observer = new MutationObserver(update);
+ this._observer.observe(scroller, { childList: true });
- update();
- }
-});
+ update();
+ }
+ },
+);
diff --git a/static/js/components/donut-chart.js b/static/js/components/donut-chart.js
index e2817a8..1ab4c76 100644
--- a/static/js/components/donut-chart.js
+++ b/static/js/components/donut-chart.js
@@ -1,19 +1,31 @@
const DONUT_ICONS = {
- beaker: '
',
- grinder: '
'
+ beaker:
+ '
',
+ grinder:
+ '
',
};
-const esc = (s) => s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+const esc = (s) =>
+ s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
class DonutChart extends HTMLElement {
static get observedAttributes() {
- return ['data-items'];
+ return ["data-items"];
}
connectedCallback() {
requestAnimationFrame(() => this.render());
- this._themeObserver = new MutationObserver(() => requestAnimationFrame(() => this.render()));
- this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+ this._themeObserver = new MutationObserver(() =>
+ requestAnimationFrame(() => this.render()),
+ );
+ this._themeObserver.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["data-theme"],
+ });
}
disconnectedCallback() {
@@ -26,22 +38,34 @@ class DonutChart extends HTMLElement {
}
render() {
- const raw = this.dataset.items || '';
- if (!raw) { this.innerHTML = ''; return; }
+ const raw = this.dataset.items || "";
+ if (!raw) {
+ this.innerHTML = "";
+ return;
+ }
- const items = raw.split('|').map(s => {
- const idx = s.lastIndexOf(':');
- if (idx === -1) return null;
- const label = s.slice(0, idx).trim();
- const count = parseInt(s.slice(idx + 1), 10);
- return (label && count > 0) ? { label, count } : null;
- }).filter(Boolean);
+ const items = raw
+ .split("|")
+ .map((s) => {
+ const idx = s.lastIndexOf(":");
+ if (idx === -1) return null;
+ const label = s.slice(0, idx).trim();
+ const count = parseInt(s.slice(idx + 1), 10);
+ return label && count > 0 ? { label, count } : null;
+ })
+ .filter(Boolean);
- if (items.length === 0) { this.innerHTML = ''; return; }
+ if (items.length === 0) {
+ this.innerHTML = "";
+ return;
+ }
const total = items.reduce((sum, i) => sum + i.count, 0);
const maxCount = items[0].count;
- const rgb = getComputedStyle(document.documentElement).getPropertyValue('--highlight-rgb').trim() || '185, 28, 28';
+ const rgb =
+ getComputedStyle(document.documentElement)
+ .getPropertyValue("--highlight-rgb")
+ .trim() || "185, 28, 28";
const colorFor = (count) => {
const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2);
return `rgba(${rgb}, ${alpha})`;
@@ -70,7 +94,7 @@ class DonutChart extends HTMLElement {
});
const legend = items.map((item, i) => {
- const pct = Math.round(item.count / total * 100);
+ const pct = Math.round((item.count / total) * 100);
return `
${esc(item.label)}
@@ -80,19 +104,19 @@ class DonutChart extends HTMLElement {
this.innerHTML = `
- ${segments.join('')}
+ ${segments.join("")}
${(() => {
const icon = DONUT_ICONS[this.dataset.icon];
- if (!icon) return '';
+ if (!icon) return "";
const s = 24;
- return `${icon} `;
+ return `${icon} `;
})()}
- ${legend.join('')}
+ ${legend.join("")}
`;
}
}
-customElements.define('donut-chart', DonutChart);
+customElements.define("donut-chart", DonutChart);
diff --git a/static/js/components/photo-capture.js b/static/js/components/photo-capture.js
index 53e3197..31c5c28 100644
--- a/static/js/components/photo-capture.js
+++ b/static/js/components/photo-capture.js
@@ -1,25 +1,31 @@
-customElements.define("brew-photo-capture", class extends HTMLElement {
- connectedCallback() {
- const input = document.createElement("input");
- input.type = "file";
- input.accept = "image/*";
- input.hidden = true;
- this.appendChild(input);
+customElements.define(
+ "brew-photo-capture",
+ class extends HTMLElement {
+ connectedCallback() {
+ 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();
+ });
- input.addEventListener("change", () => {
- const file = input.files[0];
- if (!file) return;
- const reader = new FileReader();
- reader.onload = () => {
- document.getElementById(this.getAttribute("target-input")).value = reader.result;
- document.getElementById(this.getAttribute("target-form")).requestSubmit();
- };
- reader.readAsDataURL(file);
- input.value = "";
- });
- }
-});
+ input.addEventListener("change", () => {
+ const file = input.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ document.getElementById(this.getAttribute("target-input")).value =
+ reader.result;
+ document
+ .getElementById(this.getAttribute("target-form"))
+ .requestSubmit();
+ };
+ reader.readAsDataURL(file);
+ input.value = "";
+ });
+ }
+ },
+);
diff --git a/static/js/components/searchable-select.js b/static/js/components/searchable-select.js
index 2bc79dc..c5d9b16 100644
--- a/static/js/components/searchable-select.js
+++ b/static/js/components/searchable-select.js
@@ -1,104 +1,123 @@
-customElements.define("searchable-select", class extends HTMLElement {
- connectedCallback() {
- requestAnimationFrame(() => this._setup());
- }
-
- _setup() {
- if (this._initialized) return;
- this._initialized = true;
-
- const name = this.getAttribute("name");
- const placeholder = this.getAttribute("placeholder") || "Type to search\u2026";
- const buttons = [...this.querySelectorAll("button")];
-
- const hidden = document.createElement("input");
- hidden.type = "hidden";
- hidden.name = name;
-
- const search = document.createElement("input");
- search.type = "text";
- search.className = "input-field w-full";
- search.placeholder = placeholder;
-
- const options = document.createElement("div");
- options.className = "hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface";
- buttons.forEach((btn) => options.appendChild(btn));
-
- const searchWrap = document.createElement("div");
- searchWrap.appendChild(search);
- searchWrap.appendChild(options);
-
- const display = document.createElement("span");
- display.className = "input-field w-full block pr-8";
-
- const clear = document.createElement("button");
- clear.type = "button";
- clear.className = "absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition";
- clear.innerHTML = '
';
-
- const selectedWrap = document.createElement("div");
- selectedWrap.className = "relative hidden cursor-pointer";
- selectedWrap.appendChild(display);
- selectedWrap.appendChild(clear);
-
- // Block native change events from child inputs so only our
- // CustomEvent (which carries evt.detail) reaches data-on:change.
- this.addEventListener("change", (e) => {
- if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
- }, true);
-
- this.textContent = "";
- this.style.display = "block";
- this.appendChild(hidden);
- this.appendChild(searchWrap);
- this.appendChild(selectedWrap);
-
- const initialValue = this.getAttribute("initial-value");
- if (initialValue) {
- const match = buttons.find((btn) => btn.value === initialValue);
- if (match) {
- hidden.value = match.value;
- display.textContent = match.dataset.display;
- searchWrap.classList.add("hidden");
- selectedWrap.classList.remove("hidden");
- }
+customElements.define(
+ "searchable-select",
+ class extends HTMLElement {
+ connectedCallback() {
+ requestAnimationFrame(() => this._setup());
}
- search.addEventListener("input", () => {
- const q = search.value.toLowerCase();
- options.classList.toggle("hidden", !q);
- buttons.forEach((btn) => {
- btn.style.display = btn.textContent.toLowerCase().includes(q) ? "" : "none";
+ _setup() {
+ if (this._initialized) return;
+ this._initialized = true;
+
+ const name = this.getAttribute("name");
+ const placeholder =
+ this.getAttribute("placeholder") || "Type to search\u2026";
+ const buttons = [...this.querySelectorAll("button")];
+
+ const hidden = document.createElement("input");
+ hidden.type = "hidden";
+ hidden.name = name;
+
+ const search = document.createElement("input");
+ search.type = "text";
+ search.className = "input-field w-full";
+ search.placeholder = placeholder;
+
+ const options = document.createElement("div");
+ options.className =
+ "hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface";
+ buttons.forEach((btn) => options.appendChild(btn));
+
+ const searchWrap = document.createElement("div");
+ searchWrap.appendChild(search);
+ searchWrap.appendChild(options);
+
+ const display = document.createElement("span");
+ display.className = "input-field w-full block pr-8";
+
+ const clear = document.createElement("button");
+ clear.type = "button";
+ clear.className =
+ "absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition";
+ clear.innerHTML =
+ '
';
+
+ const selectedWrap = document.createElement("div");
+ selectedWrap.className = "relative hidden cursor-pointer";
+ selectedWrap.appendChild(display);
+ selectedWrap.appendChild(clear);
+
+ // Block native change events from child inputs so only our
+ // CustomEvent (which carries evt.detail) reaches data-on:change.
+ this.addEventListener(
+ "change",
+ (e) => {
+ if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
+ },
+ true,
+ );
+
+ this.textContent = "";
+ this.style.display = "block";
+ this.appendChild(hidden);
+ this.appendChild(searchWrap);
+ this.appendChild(selectedWrap);
+
+ const initialValue = this.getAttribute("initial-value");
+ if (initialValue) {
+ const match = buttons.find((btn) => btn.value === initialValue);
+ if (match) {
+ hidden.value = match.value;
+ display.textContent = match.dataset.display;
+ searchWrap.classList.add("hidden");
+ selectedWrap.classList.remove("hidden");
+ }
+ }
+
+ search.addEventListener("input", () => {
+ const q = search.value.toLowerCase();
+ options.classList.toggle("hidden", !q);
+ buttons.forEach((btn) => {
+ btn.style.display = btn.textContent.toLowerCase().includes(q)
+ ? ""
+ : "none";
+ });
});
- });
- options.addEventListener("click", (e) => {
- const btn = e.target.closest("button");
- if (!btn || !options.contains(btn)) return;
+ 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");
+ hidden.value = btn.value;
+ display.textContent = btn.dataset.display;
+ searchWrap.classList.add("hidden");
+ selectedWrap.classList.remove("hidden");
- this.dispatchEvent(new CustomEvent("change", {
- detail: { value: btn.value, display: btn.dataset.display, data: { ...btn.dataset } },
- bubbles: true,
- }));
- });
+ this.dispatchEvent(
+ new CustomEvent("change", {
+ detail: {
+ value: btn.value,
+ display: btn.dataset.display,
+ data: { ...btn.dataset },
+ },
+ bubbles: true,
+ }),
+ );
+ });
- const doClear = () => {
- hidden.value = "";
- display.textContent = "";
- search.value = "";
- searchWrap.classList.remove("hidden");
- selectedWrap.classList.add("hidden");
- options.classList.add("hidden");
- setTimeout(() => search.focus(), 0);
+ const doClear = () => {
+ hidden.value = "";
+ display.textContent = "";
+ search.value = "";
+ searchWrap.classList.remove("hidden");
+ selectedWrap.classList.add("hidden");
+ options.classList.add("hidden");
+ setTimeout(() => search.focus(), 0);
- this.dispatchEvent(new CustomEvent("clear", { bubbles: true }));
- };
+ this.dispatchEvent(new CustomEvent("clear", { bubbles: true }));
+ };
- selectedWrap.addEventListener("click", doClear);
- }
-});
+ selectedWrap.addEventListener("click", doClear);
+ }
+ },
+);
diff --git a/static/js/components/world-map.js b/static/js/components/world-map.js
index dbf1532..c4de3d8 100644
--- a/static/js/components/world-map.js
+++ b/static/js/components/world-map.js
@@ -3,109 +3,124 @@
const WORLD_SVG = `
Author: Al MacDonald Editor: Fritz Lekschas License: CC BY-SA 3.0 ID: ISO 3166-1 or "_[a-zA-Z]" if an ISO code is not available `;
-customElements.define("world-map", class extends HTMLElement {
- static get observedAttributes() { return ["data-countries", "data-max", "data-selected"]; }
-
- connectedCallback() {
- this._scheduleRender();
- this._themeObserver = new MutationObserver(() => requestAnimationFrame(() => this._recolor()));
- this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
- }
-
- disconnectedCallback() {
- this._themeObserver?.disconnect();
- this._themeObserver = null;
- }
-
- attributeChangedCallback(name) {
- if (!this.isConnected) return;
- if (name === "data-selected") {
- this._recolor();
- } else {
- this._scheduleRender();
+customElements.define(
+ "world-map",
+ class extends HTMLElement {
+ static get observedAttributes() {
+ return ["data-countries", "data-max", "data-selected"];
}
- }
- _scheduleRender() {
- if (this._pendingRender) return;
- this._pendingRender = true;
- requestAnimationFrame(() => {
- this._pendingRender = false;
- this._render();
- });
- }
-
- _parseCountries() {
- const attr = this.getAttribute("data-countries") || "";
- const counts = new Map();
- if (attr) {
- attr.split(",").forEach((pair) => {
- const [code, count] = pair.split(":");
- if (code && count) counts.set(code.trim().toLowerCase(), parseInt(count, 10));
+ connectedCallback() {
+ this._scheduleRender();
+ this._themeObserver = new MutationObserver(() =>
+ requestAnimationFrame(() => this._recolor()),
+ );
+ this._themeObserver.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["data-theme"],
});
}
- this._counts = counts;
- this._max = parseInt(this.getAttribute("data-max") || "1", 10) || 1;
- }
- _render() {
- this._parseCountries();
+ disconnectedCallback() {
+ this._themeObserver?.disconnect();
+ this._themeObserver = null;
+ }
- this.innerHTML = "";
- const container = document.createElement("div");
- container.innerHTML = WORLD_SVG;
- const svg = container.querySelector("svg");
- if (!svg) return;
- svg.style.width = "100%";
- svg.style.height = "auto";
- svg.style.display = "block";
- this.appendChild(svg);
-
- this._recolor();
- }
-
- _recolor() {
- const svg = this.querySelector("svg");
- if (!svg) return;
- if (!this._counts) this._parseCountries();
-
- const selected = (this.getAttribute("data-selected") || "").toLowerCase();
- const counts = this._counts;
- const max = this._max;
-
- const styles = getComputedStyle(document.documentElement);
- const rgb = styles.getPropertyValue('--highlight-rgb').trim() || '185, 28, 28';
- const borderColor = styles.getPropertyValue('--text-muted').trim() || '#9ca3af';
- const surfaceAlt = styles.getPropertyValue('--surface-alt').trim() || '#f5f5f4';
- const borderMuted = styles.getPropertyValue('--border').trim() || '#d6d3d1';
-
- const applyStyle = (el, fill) => {
- el.style.fill = fill;
- el.style.stroke = borderColor;
- el.style.strokeWidth = "0.3";
- };
-
- const colorFor = (code) => {
- const count = counts.get(code);
- if (selected) {
- if (code === selected && count) return `rgba(${rgb}, 1)`;
- return count ? borderMuted : surfaceAlt;
- }
- if (count) {
- const alpha = (0.15 + 0.85 * (count / max)).toFixed(2);
- return `rgba(${rgb}, ${alpha})`;
- }
- return surfaceAlt;
- };
-
- svg.querySelectorAll("path[id], g[id]").forEach((el) => {
- const code = el.id.toLowerCase();
- const fill = colorFor(code);
- if (el.tagName === "g") {
- el.querySelectorAll("path").forEach((p) => applyStyle(p, fill));
+ attributeChangedCallback(name) {
+ if (!this.isConnected) return;
+ if (name === "data-selected") {
+ this._recolor();
} else {
- applyStyle(el, fill);
+ this._scheduleRender();
}
- });
- }
-});
+ }
+
+ _scheduleRender() {
+ if (this._pendingRender) return;
+ this._pendingRender = true;
+ requestAnimationFrame(() => {
+ this._pendingRender = false;
+ this._render();
+ });
+ }
+
+ _parseCountries() {
+ const attr = this.getAttribute("data-countries") || "";
+ const counts = new Map();
+ if (attr) {
+ attr.split(",").forEach((pair) => {
+ const [code, count] = pair.split(":");
+ if (code && count)
+ counts.set(code.trim().toLowerCase(), parseInt(count, 10));
+ });
+ }
+ this._counts = counts;
+ this._max = parseInt(this.getAttribute("data-max") || "1", 10) || 1;
+ }
+
+ _render() {
+ this._parseCountries();
+
+ this.innerHTML = "";
+ const container = document.createElement("div");
+ container.innerHTML = WORLD_SVG;
+ const svg = container.querySelector("svg");
+ if (!svg) return;
+ svg.style.width = "100%";
+ svg.style.height = "auto";
+ svg.style.display = "block";
+ this.appendChild(svg);
+
+ this._recolor();
+ }
+
+ _recolor() {
+ const svg = this.querySelector("svg");
+ if (!svg) return;
+ if (!this._counts) this._parseCountries();
+
+ const selected = (this.getAttribute("data-selected") || "").toLowerCase();
+ const counts = this._counts;
+ const max = this._max;
+
+ const styles = getComputedStyle(document.documentElement);
+ const rgb =
+ styles.getPropertyValue("--highlight-rgb").trim() || "185, 28, 28";
+ const borderColor =
+ styles.getPropertyValue("--text-muted").trim() || "#9ca3af";
+ const surfaceAlt =
+ styles.getPropertyValue("--surface-alt").trim() || "#f5f5f4";
+ const borderMuted =
+ styles.getPropertyValue("--border").trim() || "#d6d3d1";
+
+ const applyStyle = (el, fill) => {
+ el.style.fill = fill;
+ el.style.stroke = borderColor;
+ el.style.strokeWidth = "0.3";
+ };
+
+ const colorFor = (code) => {
+ const count = counts.get(code);
+ if (selected) {
+ if (code === selected && count) return `rgba(${rgb}, 1)`;
+ return count ? borderMuted : surfaceAlt;
+ }
+ if (count) {
+ const alpha = (0.15 + 0.85 * (count / max)).toFixed(2);
+ return `rgba(${rgb}, ${alpha})`;
+ }
+ return surfaceAlt;
+ };
+
+ svg.querySelectorAll("path[id], g[id]").forEach((el) => {
+ const code = el.id.toLowerCase();
+ const fill = colorFor(code);
+ if (el.tagName === "g") {
+ el.querySelectorAll("path").forEach((p) => applyStyle(p, fill));
+ } else {
+ applyStyle(el, fill);
+ }
+ });
+ }
+ },
+);
diff --git a/static/js/webauthn.js b/static/js/webauthn.js
index d65a485..d897270 100644
--- a/static/js/webauthn.js
+++ b/static/js/webauthn.js
@@ -16,7 +16,10 @@ const bufferToBase64url = (buffer) => {
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+ return btoa(binary)
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
};
// Convert server challenge options to format navigator.credentials expects
@@ -26,7 +29,7 @@ const prepareCreationOptions = (options) => {
publicKey.user.id = base64urlToBuffer(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map((cred) =>
- Object.assign({}, cred, { id: base64urlToBuffer(cred.id) })
+ Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }),
);
}
return options;
@@ -37,7 +40,7 @@ const prepareRequestOptions = (options) => {
publicKey.challenge = base64urlToBuffer(publicKey.challenge);
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map((cred) =>
- Object.assign({}, cred, { id: base64urlToBuffer(cred.id) })
+ Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }),
);
}
return options;
@@ -67,7 +70,9 @@ const serializeAuthenticationCredential = (credential) => {
authenticatorData: bufferToBase64url(response.authenticatorData),
clientDataJSON: bufferToBase64url(response.clientDataJSON),
signature: bufferToBase64url(response.signature),
- userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null,
+ userHandle: response.userHandle
+ ? bufferToBase64url(response.userHandle)
+ : null,
},
};
};
@@ -78,13 +83,18 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
const startResponse = await fetch("/api/v1/webauthn/register/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token, display_name: displayName, passkey_name: passkeyName }),
+ body: JSON.stringify({
+ token,
+ display_name: displayName,
+ passkey_name: passkeyName,
+ }),
});
if (!startResponse.ok) {
const status = startResponse.status;
if (status === 401) throw new Error("Invalid registration token.");
- if (status === 410) throw new Error("Registration token has expired or already been used.");
+ if (status === 410)
+ throw new Error("Registration token has expired or already been used.");
throw new Error(`Failed to start registration (HTTP ${status}).`);
}
@@ -106,7 +116,9 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
});
if (!finishResponse.ok) {
- throw new Error(`Failed to complete registration (HTTP ${finishResponse.status}).`);
+ throw new Error(
+ `Failed to complete registration (HTTP ${finishResponse.status}).`,
+ );
}
return finishResponse.json();
@@ -120,7 +132,8 @@ const startPasskeyAuthentication = async (queryParams) => {
if (!startResponse.ok) {
const status = startResponse.status;
- if (status === 404) throw new Error("No passkeys registered. Please register first.");
+ if (status === 404)
+ throw new Error("No passkeys registered. Please register first.");
throw new Error(`Failed to start authentication (HTTP ${status}).`);
}
@@ -157,7 +170,9 @@ const addPasskey = async (name) => {
});
if (!startResponse.ok) {
- throw new Error(`Failed to start passkey registration (HTTP ${startResponse.status}).`);
+ throw new Error(
+ `Failed to start passkey registration (HTTP ${startResponse.status}).`,
+ );
}
const { challenge_id, options } = await startResponse.json();
@@ -178,6 +193,8 @@ const addPasskey = async (name) => {
});
if (!finishResponse.ok) {
- throw new Error(`Failed to complete passkey registration (HTTP ${finishResponse.status}).`);
+ throw new Error(
+ `Failed to complete passkey registration (HTTP ${finishResponse.status}).`,
+ );
}
};
diff --git a/templates/base.html b/templates/base.html
index 35b74a7..2e1cebd 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -5,26 +5,44 @@
-
-
+
+
{% block title %}Brewlog{% endblock %}
-
+
-
+
{% include "partials/nav.html" %} {% block content %}{% endblock %}
- {{ location::location_search_js() }}
-
+
{% endblock %}
{% block content %}
-
-
+
+
-
-
- {% include "partials/tab_bar.html" %}
-
+
+ {% include "partials/tab_bar.html" %}
-
-
-
-
New Roaster
-
Describe or take a photo of a coffee bag to extract roaster details.
-
-
-
-
-
-
-
-
- {{ icons::camera("h-5 w-5") }}
-
- or
-
-
-
- {{ icons::spinner("h-5 w-5") }}
- Extracting…
-
-
-
-
-
-
-
-
-
- {% if roaster_options.is_empty() %}
-
-
Add a roaster first
-
Roasts need a roaster. Add a roaster above to enable this form.
-
- {% else %}
-
-
New Roast
-
Describe or take a photo of a coffee bag to extract roast details.
-
-
-
-
-
-
-
-
- {{ icons::camera("h-5 w-5") }}
-
- or
-
-
-
- {{ icons::spinner("h-5 w-5") }}
- Extracting…
-
-
-
-
-
- {% endif %}
-
-
-
-
- {% if roast_options.is_empty() %}
-
-
Add a roast first
-
Bags need a roast. Add a roaster and roast to enable this form.
-
- {% else %}
-
-
New Bag
-
Select a roast and enter the details.
-
-
-
- Roast*
-
- {% for roast in roast_options %}
-
- {{ roast.name }}
- {{ roast.roaster_name }}
-
- {% endfor %}
-
-
-
-
- Roast Date
-
-
-
- Amount (g)*
-
-
-
-
-
- {{ icons::plus("h-4 w-4") }} Save Bag
-
-
-
- {% endif %}
-
-
-
-
- {% if bag_options.is_empty() %}
-
-
Open a bag first
-
Brews need an open bag of coffee. Add a bag to enable this form.
-
- {% else if grinder_options.is_empty() || brewer_options.is_empty() %}
-
-
Add gear first
-
Brews need a grinder and brewer. Add gear to enable this form.
-
- {% else %}
-
-
New Brew
-
Log a cup of coffee.
-
-
-
+
+
-
Coffee
-
-
-
Bag*
-
- {% for bag in bag_options %}
-
- {{ bag.roast_name }}
- {{ bag.roaster_name }} · {{ bag.remaining }}
+ New Roaster
+
+ Describe or take a photo of a coffee bag to extract roaster details.
+
+
+
+
+
+
+
+
+
+ {{ icons::camera("h-5 w-5") }}
+
+ or
+
+
+
+ {{ icons::spinner("h-5 w-5") }} Extracting…
+
+
+
+
+
+
+
+
+
+ {% if roaster_options.is_empty() %}
+
+
Add a roaster first
+
+ Roasts need a roaster. Add a roaster above to enable this form.
+
+
+ {% else %}
+
+
New Roast
+
+ Describe or take a photo of a coffee bag to extract roast details.
+
+
+
+
+
+
+
+
+
+ {{ icons::camera("h-5 w-5") }}
+
+ or
+
+
+
+ {{ icons::spinner("h-5 w-5") }} Extracting…
+
+
+
+
+
+ {% endif %}
+
+
+
+
+ {% if roast_options.is_empty() %}
+
+
Add a roast first
+
+ Bags need a roast. Add a roaster and roast to enable this form.
+
+
+ {% else %}
+
+
New Bag
+
+ Select a roast and enter the details.
+
+
+
+
+ Roast*
+
+ {% for roast in roast_options %}
+
+ {{ roast.name }}
+ {{ roast.roaster_name }}
+
{% endfor %}
-
-
Weight (g)*
-
- -
-
- +
-
-
-
-
-
-
-
-
Grinder
-
-
-
-
- ·
-
-
-
{{ icons::pencil("h-3 w-3") }} Change
-
-
-
- Grinder*
-
- {% for grinder in grinder_options %}
- {{ grinder.label }}
- {% endfor %}
-
+ Roast Date
+
-
-
Grind Setting*
-
- -
-
- +
+
+ Amount (g)*
+
+
+
+
+
+ {{ icons::plus("h-4 w-4") }} Save Bag
+
+
+
+ {% endif %}
+
+
+
+
+ {% if bag_options.is_empty() %}
+
+
Open a bag first
+
+ Brews need an open bag of coffee. Add a bag to enable this form.
+
+
+ {% else if grinder_options.is_empty() || brewer_options.is_empty() %}
+
+
Add gear first
+
+ Brews need a grinder and brewer. Add gear to enable this form.
+
+
+ {% else %}
+
+
New Brew
+
Log a cup of coffee.
+
+
+
+
+
Coffee
+
+
+ Bag*
+
+ {% for bag in bag_options %}
+
+ {{ bag.roast_name }}
+ {{ bag.roaster_name }} ·
+ {{ bag.remaining }}
+
+ {% endfor %}
+
+
+
+
Weight (g)*
+
+
+ -
+
+
+
+ +
+
+
-
-
-
-
-
Brewer
-
-
-
-
-
·
-
+
+
+
Grinder
+
+
+
+
+ ·
+
+
+
+ {{ icons::pencil("h-3 w-3") }} Change
+
+
+
+
+
+
+ Grinder*
+
+ {% for grinder in grinder_options %}
+
+ {{ grinder.label }}
+
+ {% endfor %}
+
+
+
+
Grind Setting*
+
+
+ -
+
+
+
+ +
+
+
+
+
+
-
{{ icons::pencil("h-3 w-3") }} Change
-
-
-
-
-
- Brewer*
-
- {% for brewer in brewer_options %}
- {{ brewer.label }}
- {% endfor %}
-
-
-
- Filter Paper
-
- None
- {% for fp in filter_paper_options %}
- {{ fp.label }}
- {% endfor %}
-
-
-
-
-
-
+
+
+
Brewer
+
+
+
+
+ ·
+
+
+
+ {{ icons::pencil("h-3 w-3") }} Change
+
+
+
+
+
+
+ Brewer*
+
+ {% for brewer in brewer_options %}
+
+ {{ brewer.label }}
+
+ {% endfor %}
+
+
+
+ Filter Paper
+
+ None
+ {% for fp in filter_paper_options %}
+
+ {{ fp.label }}
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
Recipe
+
+
+
Volume (ml)*
+
+
+ -
+
+
+
+ +
+
+
+
+
+
Temp (°C)*
+
+
+ -
+
+
+
+ +
+
+
+
+
+
Time
+
+
+ -
+
+
+
+
+ +
+
+
+
+
+
+
+
+
Quick Notes
+
+
+ Good
+
+
+ Too Fast
+
+
+ Too Slow
+
+
+ Too Hot
+
+
+ Under Extracted
+
+
+ Over Extracted
+
+
+
+
+
+
+ {{ icons::beaker("h-4 w-4") }} Save Brew
+
+
+
+ {% endif %}
+
+
+
+
-
Recipe
+
New Gear
+
Add brewing equipment.
+
+
-
-
Volume (ml)*
-
- -
-
- +
-
-
-
-
Temp (°C)*
-
- -
-
- +
-
-
-
+
+ Category*
+
+ Select a category
+ Grinder
+ Brewer
+ Filter Paper
+
+
+
+ Make*
+
+
+
+ Model*
+
+
-
-
+
+
+ {{ icons::plus("h-4 w-4") }} Save Gear
+
+
+
+
+
+
+
-
Quick Notes
-
- Good
- Too Fast
- Too Slow
- Too Hot
- Under Extracted
- Over Extracted
-
-
+
New Cafe
+
+ Search for a nearby cafe or enter details manually.
+
-
-
- {{ icons::beaker("h-4 w-4") }} Save Brew
-
-
-
- {% endif %}
-
-
-
-
-
New Gear
-
Add brewing equipment.
-
-
-
-
- Category*
-
- Select a category
- Grinder
- Brewer
- Filter Paper
-
-
-
- Make*
-
-
-
- Model*
-
-
+
+
+ {{ location::location_search("$_cafeError") }}
+
-
-
- {{ icons::plus("h-4 w-4") }} Save Gear
-
-
-
-
-
-
-
-
New Cafe
-
Search for a nearby cafe or enter details manually.
-
-
-
-
- {{ location::location_search("$_cafeError") }}
-
-
-
-
-
-
-
- {% if roast_options.is_empty() || cafe_options.is_empty() %}
-
-
Add a roast and cafe first
-
Cups need both a roast and a cafe. Add them above to enable this form.
-
- {% else %}
-
-
New Cup
-
Record a coffee from a cafe visit.
-
-
-
+
+
+
+ {% if roast_options.is_empty() || cafe_options.is_empty() %}
+
+
+ Add a roast and cafe first
+
+
+ Cups need both a roast and a cafe. Add them above to enable this
+ form.
+
+
+ {% else %}
+
+
New Cup
+
+ Record a coffee from a cafe visit.
+
+
+
+
+
+ Coffee*
+
+ {% for roast in roast_options %}
+
+ {{ roast.name }}
+ {{ roast.roaster_name }}
+
+ {% endfor %}
+
+
+
+ Cafe*
+
+ {% for cafe in cafe_options %}
+
+ {{ cafe.name }}
+ {{ cafe.city }}
+
+ {% endfor %}
+
+
+
+
+
+ {{ icons::plus("h-4 w-4") }} Save Cup
+
+
+
+ {% endif %}
+
+
{% endblock %}
diff --git a/templates/pages/admin.html b/templates/pages/admin.html
index 93ac858..ec4028a 100644
--- a/templates/pages/admin.html
+++ b/templates/pages/admin.html
@@ -1,476 +1,568 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Admin{% endblock %}
{% block head %}
-
+
{% endblock %}
{% block content %}
-
+
-
-
-
-
-
Passkeys
-
Passkeys enable secure sign-in without a password.
-
+
+
+
+
+
Passkeys
+
+ Passkeys enable secure sign-in without a password.
+
+
-
- {% for passkey in passkeys %}
-
-
- {{ icons::key("h-4 w-4 text-accent shrink-0") }}
-
-
{{ passkey.name }}
-
- Added {{ passkey.created_at }}
- · {% if let Some(last_used) = passkey.last_used_at %}Last used {{ last_used }}{% else %}Never used{% endif %}
-
+
+ {% for passkey in passkeys %}
+
+
+ {{ icons::key("h-4 w-4 text-accent shrink-0") }}
+
+ {{ passkey.name }}
+
+ Added {{ passkey.created_at }} ·
+ {% if let Some(last_used) = passkey.last_used_at %}
+ Last used {{ last_used }}
+ {% else %}
+ Never used
+ {% endif %}
+
+
+
+ {% if passkeys.len() > 1 %}
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+ {% endif %}
-
- {% if passkeys.len() > 1 %}
-
- {{ icons::delete("h-4 w-4") }}
- Delete
-
- {% endif %}
+ {% endfor %}
- {% endfor %}
-
- {% if passkeys.len() <= 1 %}
-
Add another passkey before removing the only one.
- {% endif %}
+ {% if passkeys.len() <= 1 %}
+
+ Add another passkey before removing the only one.
+
+ {% endif %}
-
-
-
-
-
- {{ icons::key("h-4 w-4") }}
- New Passkey
-
-
-
-
-
-
-
-
-
-
API Tokens
-
Use tokens to authenticate the CLI or REST API.
-
-
- {% if tokens.is_empty() %}
-
No active tokens.
- {% else %}
-
- {% for token in tokens %}
-
-
- {{ icons::terminal("h-4 w-4 text-accent shrink-0") }}
-
- {{ token.name }}
-
- Created {{ token.created_at }}
- · {% if let Some(last_used) = token.last_used_at %}Last used {{ last_used }}{% else %}Never used{% endif %}
-
-
-
-
- {{ icons::delete("h-4 w-4") }}
- Revoke
-
-
- {% endfor %}
-
- {% endif %}
-
-
-
-
-
+
+
+ {{ icons::spinner("h-5 w-5") }} Follow the prompts from the browser or
+ device...
+
+
-
-
-
- {{ icons::x_mark("h-4 w-4") }}
-
-
Token created! Copy it now — it will not be shown again.
-
-
+
- {{ icons::clipboard("h-4 w-4") }} Copy
+ {{ icons::key("h-4 w-4") }} New Passkey
+
-
-
+
+
+
+
API Tokens
+
+ Use tokens to authenticate the CLI or REST API.
+
+
+
+ {% if tokens.is_empty() %}
+
No active tokens.
+ {% else %}
+
+ {% for token in tokens %}
+
+
+ {{ icons::terminal("h-4 w-4 text-accent shrink-0") }}
+
+ {{ token.name }}
+
+ Created {{ token.created_at }} ·
+ {% if let Some(last_used) = token.last_used_at %}
+ Last used {{ last_used }}
+ {% else %}
+ Never used
+ {% endif %}
+
+
+
+
+ {{ icons::delete("h-4 w-4") }} Revoke
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
- {{ icons::terminal("h-4 w-4") }}
- New Token
-
+
+
+
+
+ Token Name
+
+
+
+ {{ icons::plus("h-4 w-4") }} Create
+
+
+ {{ icons::x_mark("h-4 w-4") }} Cancel
+
+
+
+
+
+
+
+
+ {{ icons::x_mark("h-4 w-4") }}
+
+
+ Token created! Copy it now — it will not be shown again.
+
+
+
+
+ {{ icons::clipboard("h-4 w-4") }} Copy
+
+
+
+
+
+
+ {{ icons::terminal("h-4 w-4") }} New Token
+
+
-
-
+
-
-
-
-
-
Data
-
Export all coffee data as JSON, restore from a previous backup, or reset to start fresh.
-
+
+
+
+
+
Data
+
+ Export all coffee data as JSON, restore from a previous backup, or
+ reset to start fresh.
+
+
-
-
-
-
-
-
-
-
-
-
-{% if let Some(usage) = ai_usage %}
-
-
-
AI Usage
-
-
-
- Total Cost
- {{ usage.cost }}
-
-
- API Calls
- {{ usage.calls }}
-
-
- Total Tokens
- {{ usage.tokens }}
-
-
-
-
-{% endif %}
-
-
-
-
-
-
Sign Out
-
Sign out on this device.
-
-
-
-
- {{ icons::logout("h-4 w-4") }}
- Sign Out
+ data-attr:disabled="$_recomputing"
+ class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44"
+ >
+ {{ icons::refresh("h-4 w-4") }}
+ {{ icons::spinner("h-4 w-4") }}
+ Recompute Stats
-
+
+ {{ icons::delete("h-4 w-4") }} Reset
+
+
+
+
+
+
+
-
-
+
-
+ };
+
+ // --- Data management ---
+
+ const restoreFromFile = async (input) => {
+ const file = input.files[0];
+ if (!file) return;
+ input.value = "";
+
+ if (
+ !confirm(
+ "Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.",
+ )
+ ) {
+ return;
+ }
+
+ const status = document.getElementById("backup-status");
+ const error = document.getElementById("backup-error");
+ status.classList.add("hidden");
+ error.classList.add("hidden");
+
+ try {
+ const text = await file.text();
+ JSON.parse(text);
+
+ const response = await fetch("/api/v1/backup/restore", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: text,
+ });
+
+ if (response.status === 409) {
+ throw new Error(
+ "Database is not empty. Restore requires an empty database.",
+ );
+ }
+ if (!response.ok) {
+ throw new Error(`Restore failed (HTTP ${response.status}).`);
+ }
+
+ status.textContent = "Backup restored successfully.";
+ status.classList.remove("hidden");
+ } catch (err) {
+ error.textContent =
+ err instanceof SyntaxError ? "Invalid JSON file." : err.message;
+ error.classList.remove("hidden");
+ }
+ };
+
+ const resetDatabase = async () => {
+ if (
+ !confirm(
+ "Reset all coffee data?\n\nThis will permanently delete all roasters, roasts, bags, gear, brews, cafes, cups, and timeline events.\n\nThis cannot be undone.",
+ )
+ ) {
+ return;
+ }
+
+ if (!confirm("Confirm? All coffee data will be permanently deleted.")) {
+ return;
+ }
+
+ const status = document.getElementById("backup-status");
+ const error = document.getElementById("backup-error");
+ status.classList.add("hidden");
+ error.classList.add("hidden");
+
+ try {
+ const response = await fetch("/api/v1/backup/reset", {
+ method: "POST",
+ });
+
+ if (!response.ok) {
+ throw new Error(`Reset failed (HTTP ${response.status}).`);
+ }
+
+ status.textContent =
+ "Database reset successfully. All coffee data has been deleted.";
+ status.classList.remove("hidden");
+ } catch (err) {
+ error.textContent = err.message;
+ error.classList.remove("hidden");
+ }
+ };
+
+ const deletePasskey = async (id, name) => {
+ if (!confirm(`Delete passkey "${name}"? This cannot be undone.`)) return;
+
+ try {
+ const response = await fetch(`/api/v1/passkeys/${id}`, {
+ method: "DELETE",
+ });
+ if (response.ok) {
+ window.location.reload();
+ } else if (response.status === 409) {
+ alert("Cannot delete the only passkey. Add another passkey first.");
+ } else {
+ alert("Failed to delete passkey.");
+ }
+ } catch (err) {
+ alert(`Failed to delete passkey: ${err.message}`);
+ }
+ };
+
+ const revokeToken = async (id, name) => {
+ if (!confirm(`Revoke token "${name}"? This cannot be undone.`)) return;
+
+ try {
+ const response = await fetch(`/api/v1/tokens/${id}/revoke`, {
+ method: "POST",
+ });
+ if (response.ok) {
+ const row = document.getElementById(`token-row-${id}`);
+ if (row) row.remove();
+ } else {
+ alert("Failed to revoke token.");
+ }
+ } catch (err) {
+ alert(`Failed to revoke token: ${err.message}`);
+ }
+ };
+
{% endblock %}
diff --git a/templates/pages/bag.html b/templates/pages/bag.html
index 325f3d4..e3640e3 100644
--- a/templates/pages/bag.html
+++ b/templates/pages/bag.html
@@ -2,89 +2,109 @@
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %}
-{% block description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %}
-{% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %}
-{% block og_description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %}
-{% block head %}
{% endblock %}
-{% block content %}
-
-
-{# ── Coffee + map ── #}
-
- {{ detail::coffee_card(bag.roast_name, bag.roaster_name, bag.origin, bag.origin_flag, bag.region, bag.producer, bag.process, bag.tasting_notes) }}
- {{ detail::map_with_legend_2(bag.map_countries, bag.map_max, "Origin", "", "Roaster", "opacity-50") }}
-
-
-{# ── Roaster & bag info ── #}
-
- {{ detail::roaster_card(bag.roaster_name, bag.roaster_country, bag.roaster_country_flag, bag.roaster_city, bag.roaster_homepage) }}
-
-
-
Bag
-
-
-
Amount
- {{ bag.amount }}
-
-
-
Status
-
- {% if bag.closed %}
- Closed
- {% else %}
-
-
-
{{ bag.remaining }} / {{ bag.amount }}
-
- {% endif %}
-
-
- {% if let Some(rd) = bag.roast_date %}
-
-
Roast Date
- {{ rd }}
-
- {% endif %}
-
-
Opened
- {{ bag.created_date }}
-
- {% if let Some(fd) = bag.finished_date %}
-
-
Finished
- {{ fd }}
-
- {% endif %}
-
-
-
-
-{% if is_authenticated %}
-{# ── Actions ── #}
-
-
- {% if !bag.closed %}
-
- {{ icons::x_mark("h-4 w-4") }} Close Bag
-
- {% endif %}
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
-
-{% endif %}
+{% block description %}
+ {{ bag.roast_name }}
+ by {{ bag.roaster_name }} — {{ bag.amount }} bag.
+{% endblock %}
+{% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %}
+{% block og_description %}
+ {{ bag.roast_name }}
+ by {{ bag.roaster_name }} — {{ bag.amount }} bag.
+{% endblock %}
+{% block head %}
+
+{% endblock %}
+{% block content %}
+
+
+ {# ── Coffee + map ── #}
+
+ {{ detail::coffee_card(bag.roast_name, bag.roaster_name, bag.origin, bag.origin_flag, bag.region, bag.producer, bag.process, bag.tasting_notes) }}
+ {{ detail::map_with_legend_2(bag.map_countries, bag.map_max, "Origin", "", "Roaster", "opacity-50") }}
+
+
+ {# ── Roaster & bag info ── #}
+
+ {{ detail::roaster_card(bag.roaster_name, bag.roaster_country, bag.roaster_country_flag, bag.roaster_city, bag.roaster_homepage) }}
+
+
+
Bag
+
+
+
Amount
+ {{ bag.amount }}
+
+
+
Status
+
+ {% if bag.closed %}
+ Closed
+ {% else %}
+
+
+
+ {{ bag.remaining }} / {{ bag.amount }}
+
+
+ {% endif %}
+
+
+ {% if let Some(rd) = bag.roast_date %}
+
+
Roast Date
+ {{ rd }}
+
+ {% endif %}
+
+
Opened
+ {{ bag.created_date }}
+
+ {% if let Some(fd) = bag.finished_date %}
+
+
Finished
+ {{ fd }}
+
+ {% endif %}
+
+
+
+
+ {% if is_authenticated %}
+ {# ── Actions ── #}
+
+
+ {% if !bag.closed %}
+
+ {{ icons::x_mark("h-4 w-4") }} Close Bag
+
+ {% endif %}
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+
+ {% endif %}
{% endblock %}
diff --git a/templates/pages/brew.html b/templates/pages/brew.html
index 31c7c48..63be4f5 100644
--- a/templates/pages/brew.html
+++ b/templates/pages/brew.html
@@ -2,95 +2,108 @@
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
-{% block description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
-{% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
-{% block og_description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
-{% block head %}
{% endblock %}
-{% block content %}
-
-
-{# ── Coffee + map ── #}
-
- {{ detail::coffee_card(brew.roast_name, brew.roaster_name, brew.origin, brew.origin_flag, brew.region, brew.producer, brew.process, brew.tasting_notes) }}
- {{ detail::map_with_legend_2(brew.map_countries, brew.map_max, "Origin", "", "Roaster", "opacity-50") }}
-
-
-{# ── Roaster & gear ── #}
-
- {{ detail::roaster_card(brew.roaster_name, brew.roaster_country, brew.roaster_country_flag, brew.roaster_city, brew.roaster_homepage) }}
-
-
-
Gear
-
-
-
Grinder
- {{ brew.grinder_name }}
-
-
-
Brewer
- {{ brew.brewer_name }}
-
- {% if let Some(fp) = brew.filter_paper_name %}
-
-
Filter Paper
- {{ fp }}
-
- {% endif %}
-
-
-
-
-{# ── Recipe ── #}
-
-
-
Recipe
-
-
-
Coffee
- {{ brew.coffee_weight }}
-
-
-
Water
- {{ brew.water_volume }}
-
-
-
Temperature
- {{ brew.water_temp }}
-
-
-
Grind Setting
- {{ brew.grind_setting }}
-
- {% if let Some(time) = brew.brew_time %}
-
-
Brew Time
- {{ time }}
-
- {% endif %}
- {% if !brew.quick_notes_label.is_empty() %}
-
-
Notes
- {{ brew.quick_notes_label }}
-
- {% endif %}
-
-
-
-
-{% if is_authenticated %}
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
-{% endif %}
+{% block description %}
+ {{ brew.roast_name }}
+ by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee,
+ {{ brew.water_volume }} water.
+{% endblock %}
+{% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
+{% block og_description %}
+ {{ brew.roast_name }}
+ by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee,
+ {{ brew.water_volume }} water.
+{% endblock %}
+{% block head %}
+
+{% endblock %}
+{% block content %}
+
+
+ {# ── Coffee + map ── #}
+
+ {{ detail::coffee_card(brew.roast_name, brew.roaster_name, brew.origin, brew.origin_flag, brew.region, brew.producer, brew.process, brew.tasting_notes) }}
+ {{ detail::map_with_legend_2(brew.map_countries, brew.map_max, "Origin", "", "Roaster", "opacity-50") }}
+
+
+ {# ── Roaster & gear ── #}
+
+ {{ detail::roaster_card(brew.roaster_name, brew.roaster_country, brew.roaster_country_flag, brew.roaster_city, brew.roaster_homepage) }}
+
+
+
Gear
+
+
+
Grinder
+ {{ brew.grinder_name }}
+
+
+
Brewer
+ {{ brew.brewer_name }}
+
+ {% if let Some(fp) = brew.filter_paper_name %}
+
+
Filter Paper
+ {{ fp }}
+
+ {% endif %}
+
+
+
+
+ {# ── Recipe ── #}
+
+
+
Recipe
+
+
+
Coffee
+ {{ brew.coffee_weight }}
+
+
+
Water
+ {{ brew.water_volume }}
+
+
+
Temperature
+ {{ brew.water_temp }}
+
+
+
Grind Setting
+ {{ brew.grind_setting }}
+
+ {% if let Some(time) = brew.brew_time %}
+
+
Brew Time
+ {{ time }}
+
+ {% endif %}
+ {% if !brew.quick_notes_label.is_empty() %}
+
+
Notes
+ {{ brew.quick_notes_label }}
+
+ {% endif %}
+
+
+
+
+ {% if is_authenticated %}
+
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+ {% endif %}
{% endblock %}
diff --git a/templates/pages/cafe.html b/templates/pages/cafe.html
index 78251b5..53e46fa 100644
--- a/templates/pages/cafe.html
+++ b/templates/pages/cafe.html
@@ -3,53 +3,67 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ cafe.name }}{% endblock %}
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
-{% block og_description %}{{ cafe.name }} — {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }}{% endblock %}
-{% block head %}
{% endblock %}
-{% block content %}
-
-
-
-
-
Details
-
-
-
City
- {{ cafe.city }}
-
-
-
Country
-
- {% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }}
-
-
- {% if let Some(url) = cafe.website %}
-
- {% endif %}
-
-
-
- {{ detail::map_with_legend_1(cafe.map_countries, cafe.map_max, "Cafe", "") }}
-
-
-{% if is_authenticated %}
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
-{% endif %}
+{% block og_description %}
+ {{ cafe.name }}
+ — {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }}
+{% endblock %}
+{% block head %}
+
+{% endblock %}
+{% block content %}
+
+
+
+
+
Details
+
+
+
City
+ {{ cafe.city }}
+
+
+
Country
+
+ {% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }}
+
+
+ {% if let Some(url) = cafe.website %}
+
+ {% endif %}
+
+
+
+ {{ detail::map_with_legend_1(cafe.map_countries, cafe.map_max, "Cafe", "") }}
+
+
+ {% if is_authenticated %}
+
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+ {% endif %}
{% endblock %}
diff --git a/templates/pages/checkin.html b/templates/pages/checkin.html
index ca62a9b..3727281 100644
--- a/templates/pages/checkin.html
+++ b/templates/pages/checkin.html
@@ -1,328 +1,377 @@
-{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import
-"partials/scan_input.html" as scan %} {% import "partials/location_search.html" as location %} {%
-block title %}Brewlog · Check In{% endblock %} {% block head %}
-
-{% endblock %} {% block content %}
-
-
-
-
-
-
-
-
+ {
+ {
+ location::location_search_js();
+ }
+ }
+
+{% endblock %}
+{% block content %}
+
-
- {{ icons::location("h-4 w-4 text-accent shrink-0") }}
-
-
+
+
+
+
-
+
- {{ icons::pencil("h-3 w-3") }} Change
-
-
-
-
-
-
- {{ icons::bag("h-4 w-4 text-accent shrink-0") }}
-
-
+
+ {{ icons::location("h-4 w-4 text-accent shrink-0") }}
+
+
+
+
+ {{ icons::pencil("h-3 w-3") }} Change
+
-
+
- {{ icons::pencil("h-3 w-3") }} Change
-
-
-
-
-
-
-
-
-
-
{{ location::location_search("$_error") }}
-
-
-
-
Confirm cafe details
-
Review and edit the details before saving.
-
-
- Name *
-
-
-
- City
-
-
-
- Country
-
-
-
- Website
-
-
-
-
-
- Back
-
-
- Next {{ icons::chevron_right("h-4 w-4") }}
-
-
+
+ {{ icons::bag("h-4 w-4 text-accent shrink-0") }}
+
+
-
-
- {% if !cafe_options.is_empty() %}
-
-
Or choose a saved cafe:
-
- {% for cafe in cafe_options %}
-
- {{ cafe.name }}
- {{ cafe.city }}
-
- {% endfor %}
-
-
- {% endif %}
+
+ {{ icons::pencil("h-3 w-3") }} Change
+
-
-
-
-
-
Select a coffee
+
+
-
-
- Scan a bag to identify the coffee, or select from existing roasts below.
-
+
+
+
+
+ {{ location::location_search("$_error") }}
+
+
+
+
+
+
+ {% if !cafe_options.is_empty() %}
+
+
+ Or choose a saved cafe:
+
+
+ {% for cafe in cafe_options %}
+
+ {{ cafe.name }}
+ {{ cafe.city }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
Select a coffee
+
+
+
+ Scan a bag to identify the coffee, or select from existing roasts
+ below.
+
+
+
+ {{
+ scan::scan_input("checkin-scan-form", "checkin-image", "$_extracting",
+ "$_extractError", "Describe a coffee bag…", "Scanning…")
+ }}
+
+
+
+
+ Scanned:
+
+
+ {% if !roast_options.is_empty() %}
+
+
+ Or select an existing roast:
+
+
+ {% for roast in roast_options %}
+
+ {{ roast.name }}
+ {{ roast.roaster_name }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+ Review & Submit
+
+
+
+
Cafe
+
+
+
+
Coffee
+
+
+
- {{ scan::scan_input("checkin-scan-form", "checkin-image", "$_extracting",
- "$_extractError", "Describe a coffee bag…", "Scanning…") }}
+
+
+
+
+
+
+
+
+
+ {{ icons::check("h-4 w-4") }} Check In
+
-
-
- Scanned:
-
-
- {% if !roast_options.is_empty() %}
-
-
Or select an existing roast:
-
- {% for roast in roast_options %}
-
- {{ roast.name }}
- {{ roast.roaster_name }}
-
- {% endfor %}
-
-
- {% endif %}
-
-
-
-
-
-
Review & Submit
-
-
-
Cafe
-
-
-
-
Coffee
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ icons::check("h-4 w-4") }} Check In
-
-
-
-
-
+
{% endblock %}
diff --git a/templates/pages/cli_callback.html b/templates/pages/cli_callback.html
index d6461ed..6ef34c4 100644
--- a/templates/pages/cli_callback.html
+++ b/templates/pages/cli_callback.html
@@ -1,33 +1,38 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · CLI Authentication{% endblock %}
{% block content %}
-
-
- {% if token.is_some() %}
-
- {{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }}
-
CLI Authenticated
-
- Your CLI has been authenticated successfully. You can close this window and return to the terminal.
-
+
+
+ {% if token.is_some() %}
+
+ {{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }}
+
CLI Authenticated
+
+ Your CLI has been authenticated successfully. You can close this
+ window and return to the terminal.
+
+
+ {% else if error.is_some() %}
+
+ {{ icons::x_circle("h-10 w-10 text-red-600 dark:text-red-400") }}
+
+ Authentication Failed
+
+
+ {{ error.as_ref().unwrap() }}
+
+
+ {% else %}
+
+ {{ icons::spinner("h-10 w-10") }}
+
CLI Authentication
+
+ Processing authentication…
+
+
+ {% endif %}
- {% else if error.is_some() %}
-
- {{ icons::x_circle("h-10 w-10 text-red-600 dark:text-red-400") }}
-
Authentication Failed
-
- {{ error.as_ref().unwrap() }}
-
-
- {% else %}
-
- {{ icons::spinner("h-10 w-10") }}
-
CLI Authentication
-
- Processing authentication…
-
-
- {% endif %}
-
{% endblock %}
diff --git a/templates/pages/cup.html b/templates/pages/cup.html
index ae0464c..419a63f 100644
--- a/templates/pages/cup.html
+++ b/templates/pages/cup.html
@@ -2,66 +2,83 @@
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
-{% block description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
-{% block og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %}
-{% block og_description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
-{% block head %}
{% endblock %}
-{% block content %}
-
-
-{# ── Coffee + map ── #}
-
- {{ detail::coffee_card(cup.roast_name, cup.roaster_name, cup.origin, cup.origin_flag, cup.region, cup.producer, cup.process, cup.tasting_notes) }}
- {{ detail::map_with_legend_3(cup.map_countries, cup.map_max, "Cafe", "", "Origin", "opacity-65", "Roaster", "opacity-35") }}
-
-
-{# ── Roaster & cafe ── #}
-
- {{ detail::roaster_card(cup.roaster_name, cup.roaster_country, cup.roaster_country_flag, cup.roaster_city, cup.roaster_homepage) }}
-
-
-
Cafe
-
-
-
Name
- {{ cup.cafe_name }}
-
-
-
Country
-
- {% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }} {% endif %}{{ cup.cafe_country }}
-
-
-
-
City
- {{ cup.cafe_city }}
-
- {% if let Some(url) = cup.cafe_website %}
-
- {% endif %}
-
-
-
-
-{% if is_authenticated %}
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
-{% endif %}
+{% block description %}
+ {{ cup.roast_name }}
+ by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.
+{% endblock %}
+{% block og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %}
+{% block og_description %}
+ {{ cup.roast_name }}
+ by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.
+{% endblock %}
+{% block head %}
+
+{% endblock %}
+{% block content %}
+
+
+ {# ── Coffee + map ── #}
+
+ {{ detail::coffee_card(cup.roast_name, cup.roaster_name, cup.origin, cup.origin_flag, cup.region, cup.producer, cup.process, cup.tasting_notes) }}
+ {{ detail::map_with_legend_3(cup.map_countries, cup.map_max, "Cafe", "", "Origin", "opacity-65", "Roaster", "opacity-35") }}
+
+
+ {# ── Roaster & cafe ── #}
+
+ {{ detail::roaster_card(cup.roaster_name, cup.roaster_country, cup.roaster_country_flag, cup.roaster_city, cup.roaster_homepage) }}
+
+
+
Cafe
+
+
+
Name
+ {{ cup.cafe_name }}
+
+
+
Country
+
+ {% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }}{% endif %}{{ cup.cafe_country }}
+
+
+
+
City
+ {{ cup.cafe_city }}
+
+ {% if let Some(url) = cup.cafe_website %}
+
+ {% endif %}
+
+
+
+
+ {% if is_authenticated %}
+
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+ {% endif %}
{% endblock %}
diff --git a/templates/pages/data.html b/templates/pages/data.html
index f12d228..a0b662a 100644
--- a/templates/pages/data.html
+++ b/templates/pages/data.html
@@ -1,28 +1,27 @@
-{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Data{% endblock %}
+{% extends "base.html" %} {% import "partials/icons.html" as icons %}
+{% block title %}Brewlog · Data{% endblock %}
{% block content %}
-
- Data
- Browse all coffee data.
-
+
+ Data
+ Browse all coffee data.
+
-
- {% include "partials/tab_bar.html" %}
-
-
-
+
+ {% include "partials/tab_bar.html" %}
+
+
+
+
+
+
{{ content|safe }}
-
- {{ content|safe }}
-
-
-
-
+
{% endblock %}
diff --git a/templates/pages/gear.html b/templates/pages/gear.html
index f4c99cf..8be082e 100644
--- a/templates/pages/gear.html
+++ b/templates/pages/gear.html
@@ -2,43 +2,51 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
-{% block og_description %}{{ gear.make }} {{ gear.model }} — {{ gear.category_label }}{% endblock %}
-{% block head %}
{% endblock %}
-{% block content %}
-
-
-
-
Details
-
-
-
Category
- {{ gear.category_label }}
-
-
-
Make
- {{ gear.make }}
-
-
-
Model
- {{ gear.model }}
-
-
-
-
-{% if is_authenticated %}
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
-{% endif %}
+{% block og_description %}
+ {{ gear.make }}
+ {{ gear.model }}
+ — {{ gear.category_label }}
+{% endblock %}
+{% block head %}
+
+{% endblock %}
+{% block content %}
+
+
+
+
Details
+
+
+
Category
+ {{ gear.category_label }}
+
+
+
Make
+ {{ gear.make }}
+
+
+
Model
+ {{ gear.model }}
+
+
+
+
+ {% if is_authenticated %}
+
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+ {% endif %}
{% endblock %}
diff --git a/templates/pages/home.html b/templates/pages/home.html
index b980809..b36f13a 100644
--- a/templates/pages/home.html
+++ b/templates/pages/home.html
@@ -1,376 +1,492 @@
-{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import
-"partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %}
-
-{% endblock %} {% block content %}
-
-{% if is_authenticated %}
-
-
-
-
-
- {{ icons::spinner("h-5 w-5") }}
- Waiting for response…
-
-
-
-
-
-
-
- {% include "partials/forms/scan_result_form.html" %}
-
-
-
-{% endif %}
-
-
-
-
- {% if !recent_brews.is_empty() %}
-
-
- {{ icons::chevron_left("h-5 w-5") }}
-
-
- {% for brew in recent_brews %} {{ brew_card::card(brew, is_authenticated) }} {% endfor %}
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
- {% else %}
-
-
- {% for _ in 0..6 %}
-
- {% endfor %}
-
-
- No brews yet
-
-
- {% endif %}
-
-
-
-
-
- {% if !open_bags.is_empty() %}
-
-
- {{ icons::chevron_left("h-5 w-5") }}
-
-
- {% for bag in open_bags %} {{ bag_card::card(bag, is_authenticated) }} {% endfor %}
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
- {% else %}
-
-
- {% for _ in 0..6 %}
-
- {% endfor %}
-
-
- No open bags
-
-
- {% endif %}
-
-
-
-
-
- {% if !recent_events.is_empty() %}
-
- {% else %}
-
-
- {% for _ in 0..3 %}
-
- {% endfor %}
-
-
- No activity yet
-
-
- {% endif %}
-
-
-
-
-
- {% if !stats.is_empty() && !stat_cards.is_empty() %}
-
-
- {{ icons::chevron_left("h-5 w-5") }}
-
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
- {% else %}
-
-
- {% for _ in 0..6 %}
-
- {% endfor %}
-
-
- No stats yet
-
-
- {% endif %}
-
-
-
-
-
- {% if !stats.is_empty() %}
-
-
- {{ icons::chevron_left("h-5 w-5") }}
-
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
- {% else %}
-
-
- {% for _ in 0..6 %}
-
- {% endfor %}
-
-
- No data yet
-
-
- {% endif %}
-
+{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %}
+{%
+ import
+ "partials/brew_card.html" as brew_card
+%}
+{% import "partials/icons.html" as icons %}
+{% block title %}Brewlog{% endblock %}
+{% block head %}
+
+{% endblock %}
+{% block content %}
+
+ {% if is_authenticated %}
+
+
+
+
+
+ {{ icons::spinner("h-5 w-5") }} Waiting for response…
+
+
+
+
+
+
+
+ {% include "partials/forms/scan_result_form.html" %}
+
+
+
+ {% endif %}
+
+
+
+
+ {% if !recent_brews.is_empty() %}
+
+
+ {{ icons::chevron_left("h-5 w-5") }}
+
+
+ {% for brew in recent_brews %}{{ brew_card::card(brew, is_authenticated) }}{% endfor %}
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+ {% else %}
+
+
+ {% for _ in 0..6 %}
+
+ {% endfor %}
+
+
+ No brews yet
+
+
+ {% endif %}
+
+
+
+
+
+ {% if !open_bags.is_empty() %}
+
+
+ {{ icons::chevron_left("h-5 w-5") }}
+
+
+ {% for bag in open_bags %}{{ bag_card::card(bag, is_authenticated) }}{% endfor %}
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+ {% else %}
+
+
+ {% for _ in 0..6 %}
+
+ {% endfor %}
+
+
+ No open bags
+
+
+ {% endif %}
+
+
+
+
+
+ {% if !recent_events.is_empty() %}
+
+ {% else %}
+
+
+ {% for _ in 0..3 %}
+
+ {% endfor %}
+
+
+ No activity yet
+
+
+ {% endif %}
+
+
+
+
+
+ {% if !stats.is_empty() && !stat_cards.is_empty() %}
+
+
+ {{ icons::chevron_left("h-5 w-5") }}
+
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+ {% else %}
+
+
+ {% for _ in 0..6 %}
+
+ {% endfor %}
+
+
+ No stats yet
+
+
+ {% endif %}
+
+
+
+
+
+ {% if !stats.is_empty() %}
+
+
+ {{ icons::chevron_left("h-5 w-5") }}
+
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+ {% else %}
+
+
+ {% for _ in 0..6 %}
+
+ {% endfor %}
+
+
+ No data yet
+
+
+ {% endif %}
+
{% endblock %}
diff --git a/templates/pages/login.html b/templates/pages/login.html
index 310ac52..646d61c 100644
--- a/templates/pages/login.html
+++ b/templates/pages/login.html
@@ -1,78 +1,89 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Login{% endblock %}
{% block head %}
-
+
{% endblock %}
{% block content %}
-
-
-
Login
-
- Sign in with a passkey to access the coffee log.
-
+
+
+
Login
+
+ Sign in with a passkey to access the coffee log.
+
-
+
-
- This browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
-
+
+ This browser does not support passkeys. Please use a modern browser
+ (Chrome, Firefox, Safari, or Edge).
+
-
-
- {{ icons::key("h-4 w-4") }} Sign in with Passkey
-
+
+
+ {{ icons::key("h-4 w-4") }} Sign in with Passkey
+
-
-
Waiting for passkey...
+
+
Waiting for passkey...
+
-
-
+
{% endblock %}
diff --git a/templates/pages/register.html b/templates/pages/register.html
index 670efa9..dfc79a4 100644
--- a/templates/pages/register.html
+++ b/templates/pages/register.html
@@ -1,107 +1,118 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Register{% endblock %}
{% block head %}
-
+
{% endblock %}
{% block content %}
-
-
-
Register
-
- Create an account by registering a passkey. This will be used to sign in going forward.
-
+
+
+
Register
+
+ Create an account by registering a passkey. This will be used to sign in
+ going forward.
+
-
+
-
- This browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
-
+
+ This browser does not support passkeys. Please use a modern browser
+ (Chrome, Firefox, Safari, or Edge).
+
-
-
-
+
{% endblock %}
diff --git a/templates/pages/roast.html b/templates/pages/roast.html
index 3aa46f3..99a8af5 100644
--- a/templates/pages/roast.html
+++ b/templates/pages/roast.html
@@ -3,34 +3,42 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ roast.name }}{% endblock %}
{% block og_title %}{{ roast.name }} — Brewlog{% endblock %}
-{% block og_description %}{{ roast.name }} by {{ roast.roaster_name }} — {{ roast.origin }}{% endblock %}
-{% block head %}
{% endblock %}
-{% block content %}
-
-
-
- {{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes) }}
- {{ detail::map_with_legend_2(roast.map_countries, roast.map_max, "Origin", "", "Roaster", "opacity-50") }}
-
-
-
- {{ detail::roaster_card(roast.roaster_name, roast.roaster_country, roast.roaster_country_flag, roast.roaster_city, roast.roaster_homepage) }}
-
-
-{% if is_authenticated %}
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
-{% endif %}
+{% block og_description %}
+ {{ roast.name }}
+ by {{ roast.roaster_name }} — {{ roast.origin }}
+{% endblock %}
+{% block head %}
+
+{% endblock %}
+{% block content %}
+
+
+
+ {{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes) }}
+ {{ detail::map_with_legend_2(roast.map_countries, roast.map_max, "Origin", "", "Roaster", "opacity-50") }}
+
+
+
+ {{ detail::roaster_card(roast.roaster_name, roast.roaster_country, roast.roaster_country_flag, roast.roaster_city, roast.roaster_homepage) }}
+
+
+ {% if is_authenticated %}
+
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+ {% endif %}
{% endblock %}
diff --git a/templates/pages/roaster.html b/templates/pages/roaster.html
index 1c326b4..e8ee9f9 100644
--- a/templates/pages/roaster.html
+++ b/templates/pages/roaster.html
@@ -3,55 +3,71 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ roaster.name }}{% endblock %}
{% block og_title %}{{ roaster.name }} — Brewlog{% endblock %}
-{% block og_description %}{{ roaster.name }} — {{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %}, {{ c }}{% endif %}{% endblock %}
-{% block head %}
{% endblock %}
-{% block content %}
-
-
-
-
-
Details
-
-
-
Country
-
- {% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}
-
-
- {% if let Some(c) = roaster.city %}
-
-
City
- {{ c }}
-
- {% endif %}
- {% if let Some(url) = roaster.homepage %}
-
- {% endif %}
-
-
-
- {{ detail::map_with_legend_1(roaster.map_countries, roaster.map_max, "Roaster", "") }}
-
-
-{% if is_authenticated %}
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
-{% endif %}
+{% block og_description %}
+ {{ roaster.name }}
+ — {{ roaster.country_flag }}
+ {{ roaster.country }}{% if let Some(c) = roaster.city %}, {{ c }}{% endif %}
+{% endblock %}
+{% block head %}
+
+{% endblock %}
+{% block content %}
+
+
+
+
+
Details
+
+
+
Country
+
+ {% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }}
+
+
+ {% if let Some(c) = roaster.city %}
+
+
City
+ {{ c }}
+
+ {% endif %}
+ {% if let Some(url) = roaster.homepage %}
+
+ {% endif %}
+
+
+
+ {{ detail::map_with_legend_1(roaster.map_countries, roaster.map_max, "Roaster", "") }}
+
+
+ {% if is_authenticated %}
+
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+ {% endif %}
{% endblock %}
diff --git a/templates/pages/stats.html b/templates/pages/stats.html
index 64b2c00..e21215f 100644
--- a/templates/pages/stats.html
+++ b/templates/pages/stats.html
@@ -1,182 +1,247 @@
-{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/histogram.html" as histogram %} {% block title %}Brewlog · Stats{% endblock %}
+{% extends "base.html" %} {% import "partials/icons.html" as icons %}
+{% import "partials/histogram.html" as histogram %}
+{% block title %}Brewlog · Stats{% endblock %}
{% block og_title %}Stats — Brewlog{% endblock %}
-{% block og_description %}Aggregated coffee data across origins, consumption, and brewing.{% endblock %}
-{% block head %}
{% endblock %}
+{% block og_description %}
+ Aggregated coffee data across origins, consumption, and brewing.
+{% endblock %}
+{% block head %}
+
+{% endblock %}
{% block content %}
-
-
-
Stats
- {% if !cache_age.is_empty() %}
- Updated {{ cache_age }}
- {% endif %}
-
- Aggregated coffee data across origins, consumption, and brewing.
-
+
+
+
Stats
+ {% if !cache_age.is_empty() %}
+ Updated {{ cache_age }}
+ {% endif %}
+
+
+ Aggregated coffee data across origins, consumption, and brewing.
+
+
-{% if has_data %}
-
- {% include "partials/tab_bar.html" %}
+ {% if has_data %}
+
+ {% include "partials/tab_bar.html" %}
-
- {{ content|safe }}
-
-
+
{{ content|safe }}
+
-
-
-
Roast Stats
-
-
-
- {{ icons::map("h-6 w-6 text-accent") }}
- {{ roast_summary.unique_origins }}
- Origins
-
-
- {{ icons::location("h-6 w-6 text-accent") }}
- {% match roast_summary.top_origin %}{% when Some with (v) %}{{ v }}{% when None %}—{% endmatch %}
- Top Origin
-
-
- {{ icons::fire("h-6 w-6 text-accent") }}
- {% match roast_summary.top_roaster %}{% when Some with (v) %}{{ v }}{% when None %}—{% endmatch %}
- Top Roaster
-
-
- {% if !roast_summary.origin_counts.is_empty() || !roast_summary.flavour_counts.is_empty() %}
-
- {% if !roast_summary.origin_counts.is_empty() %}
-
-
Top 5 Origins
- {{ histogram::bar_chart(roast_summary.origin_counts, roast_summary.max_origin_count) }}
-
- {% endif %}
- {% if !roast_summary.flavour_counts.is_empty() %}
-
-
Top 5 Flavours
- {{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }}
-
- {% endif %}
-
- {% endif %}
-
-
-
-
-
Consumption
-
-
-
- {{ icons::coffee_bean("h-6 w-6 text-accent") }}
- {{ consumption_30d_weight }}
- Last 30 Days
-
-
- {{ icons::coffee_bean("h-6 w-6 text-accent") }}
- {{ consumption_all_time_weight }}
- All Time
-
-
- {{ icons::beaker("h-6 w-6 text-accent") }}
- {{ consumption.brews_last_30_days }}
- Brews (30d)
-
-
- {{ icons::beaker("h-6 w-6 text-accent") }}
- {{ consumption.brews_all_time }}
- Brews (All Time)
-
-
-
-
-
-
-
Brewing
-
- {% if !brewing_summary.brewer_counts.is_empty() || !brewing_summary.grinder_counts.is_empty() %}
-
- {% if !brewing_summary.brewer_counts.is_empty() %}
-
-
Brewer Usage
-
-
- {% endif %}
- {% if !brewing_summary.grinder_counts.is_empty() %}
-
-
Grinder Usage
-
-
- {% endif %}
-
- {% endif %}
-
- {% if !grinder_weights.is_empty() %}
-
-
Coffee per Grinder
- {{ histogram::bar_chart_weight(grinder_weights, max_grinder_weight) }}
-
- {% endif %}
- {% if !brewing_summary.brew_time_distribution.is_empty() %}
-
-
Brew Time Distribution
- {{ histogram::bar_chart(brewing_summary.brew_time_distribution, brewing_summary.max_brew_time_count) }}
-
- {% endif %}
-
-
-{% else %}
-
-
-
-
+
+
Roast Stats
+
- {% for _ in 0..3 %}
-
-
-
-
+
+ {{ icons::map("h-6 w-6 text-accent") }}
+ {{ roast_summary.unique_origins }}
+ Origins
- {% endfor %}
-
-
-
-
-
-
- {% for _ in 0..4 %}
-
-
-
-
+
+ {{ icons::location("h-6 w-6 text-accent") }}
+ {% match roast_summary.top_origin %}{% when Some with (v) %}{{ v }}{% when None %}—{% endmatch %}
+ Top Origin
+
+
+ {{ icons::fire("h-6 w-6 text-accent") }}
+ {% match roast_summary.top_roaster %}{% when Some with (v) %}{{ v }}{% when None %}—{% endmatch %}
+ Top Roaster
- {% endfor %}
-
-
-
-
-
- {% for _ in 0..2 %}
-
-
-
- {% for _ in 0..4 %}
-
-
-
-
+ {% if !roast_summary.origin_counts.is_empty() || !roast_summary.flavour_counts.is_empty() %}
+
+ {% if !roast_summary.origin_counts.is_empty() %}
+
+
+ Top 5 Origins
+
+ {{ histogram::bar_chart(roast_summary.origin_counts, roast_summary.max_origin_count) }}
+ {% endif %}
+ {% if !roast_summary.flavour_counts.is_empty() %}
+
+
+ Top 5 Flavours
+
+ {{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }}
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+
Consumption
+
+
+
+ {{ icons::coffee_bean("h-6 w-6 text-accent") }}
+ {{ consumption_30d_weight }}
+ Last 30 Days
+
+
+ {{ icons::coffee_bean("h-6 w-6 text-accent") }}
+ {{ consumption_all_time_weight }}
+ All Time
+
+
+ {{ icons::beaker("h-6 w-6 text-accent") }}
+ {{ consumption.brews_last_30_days }}
+ Brews (30d)
+
+
+ {{ icons::beaker("h-6 w-6 text-accent") }}
+ {{ consumption.brews_all_time }}
+ Brews (All Time)
+
+
+
+
+
+
+
Brewing
+
+ {% if !brewing_summary.brewer_counts.is_empty() || !brewing_summary.grinder_counts.is_empty() %}
+
+ {% if !brewing_summary.brewer_counts.is_empty() %}
+
+
Brewer Usage
+
+
+ {% endif %}
+ {% if !brewing_summary.grinder_counts.is_empty() %}
+
+
+ Grinder Usage
+
+
+
+ {% endif %}
+
+ {% endif %}
+
+ {% if !grinder_weights.is_empty() %}
+
+
+ Coffee per Grinder
+
+ {{ histogram::bar_chart_weight(grinder_weights, max_grinder_weight) }}
+
+ {% endif %}
+ {% if !brewing_summary.brew_time_distribution.is_empty() %}
+
+
+ Brew Time Distribution
+
+ {{ histogram::bar_chart(brewing_summary.brew_time_distribution, brewing_summary.max_brew_time_count) }}
+
+ {% endif %}
+
+
+ {% else %}
+
+
+
+
+
+
+ {% for _ in 0..3 %}
+
{% endfor %}
-
- {% endfor %}
+
+
+
+
+
+ {% for _ in 0..4 %}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% for _ in 0..2 %}
+
+
+
+ {% for _ in 0..4 %}
+
+ {% endfor %}
+
+
+ {% endfor %}
+
+
-
-
-
- No stats yet
-
-
-{% endif %}
+
+ No stats yet
+
+
+ {% endif %}
{% endblock %}
diff --git a/templates/pages/timeline.html b/templates/pages/timeline.html
index e6f5b3f..d04a24a 100644
--- a/templates/pages/timeline.html
+++ b/templates/pages/timeline.html
@@ -1,219 +1,241 @@
-{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %} {% block content %}
-
-
- {% if months.is_empty() %}
-
- No events yet.
-
- {% else %}
-
- {# Single central timeline line for all months #}
-
- {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
-
- {% endif %}
-
-
+
-
+ No events yet.
+
+ {% else %}
+
+ {# Single central timeline line for all months #}
+
+ {% for month in months %}{% include "partials/timeline_month.html" %}{% endfor %}
+
+ {% endif %}
+
+
-
↓
- Load more
-
-
Loading…
-
No more events.
-
-
-
-
-
+
+ ↓
+ Load more
+
+
+ Loading…
+
+
+ No more events.
+
+
+
+
+
+
-
+ const setHasMore = (hasMore, url) => {
+ hasMorePages = hasMore;
+ nextUrl = url || "";
+ loader.dataset.hasMore = hasMore ? "true" : "false";
+ loader.dataset.nextUrl = nextUrl;
+
+ if (!hasMore) {
+ if (loadMoreButton) loadMoreButton.style.display = "none";
+ if (endLine) endLine.style.display = "";
+ observer.disconnect();
+ }
+ };
+
+ const ensureItemsContainer = () => {
+ if (itemsContainer) return itemsContainer;
+ // First load on a previously empty timeline: create the container
+ const container = document.createElement("div");
+ container.className = "timeline-list relative";
+ container.id = "timeline-items";
+ const line = document.createElement("div");
+ line.className = "timeline-line absolute top-0 bottom-0 w-1 bg-border";
+ line.setAttribute("aria-hidden", "true");
+ container.appendChild(line);
+ section.insertBefore(container, loader);
+ return container;
+ };
+
+ const appendMonths = (chunk) => {
+ const monthsFragment = chunk.querySelector("[data-chunk-months]");
+ if (!monthsFragment) return;
+
+ const container = ensureItemsContainer();
+
+ for (const node of Array.from(monthsFragment.children)) {
+ // Skip duplicate month headings (same month spanning a page boundary)
+ if (
+ node.hasAttribute("data-timeline-month") &&
+ document.getElementById(node.id)
+ ) {
+ continue;
+ }
+ container.appendChild(node);
+ }
+ };
+
+ const clearEmptyState = () => {
+ if (emptyState && !emptyState.hidden) {
+ emptyState.hidden = true;
+ loader.dataset.empty = "false";
+ }
+ };
+
+ const loadMore = async () => {
+ if (loading || !hasMorePages || !nextUrl) return;
+
+ loading = true;
+ if (statusLine) {
+ statusLine.hidden = false;
+ }
+ if (errorLine) {
+ errorLine.hidden = true;
+ }
+
+ try {
+ const response = await fetch(nextUrl, {
+ headers: {
+ "X-Requested-With": "fetch",
+ "datastar-request": "true",
+ },
+ });
+ if (!response.ok) {
+ throw new Error(`Unexpected response: ${response.status}`);
+ }
+ const html = await response.text();
+ const template = document.createElement("template");
+ template.innerHTML = html.trim();
+ const chunk = template.content.querySelector("[data-timeline-chunk]");
+ if (!chunk) {
+ throw new Error("Invalid timeline chunk payload");
+ }
+
+ appendMonths(chunk);
+ clearEmptyState();
+
+ const hasMore = chunk.dataset.hasMore === "true";
+ const url = chunk.dataset.nextUrl || "";
+ setHasMore(hasMore, url);
+ } catch (error) {
+ console.error(error);
+ if (errorLine) {
+ errorLine.textContent =
+ "Failed to load more events. Please try again.";
+ errorLine.hidden = false;
+ }
+ } finally {
+ loading = false;
+ if (statusLine) {
+ statusLine.hidden = true;
+ }
+ }
+ };
+
+ if (loadMoreButton) {
+ loadMoreButton.addEventListener("click", () => {
+ void loadMore();
+ });
+ }
+
+ if (hasMorePages && loader.dataset.empty !== "true") {
+ observer.observe(sentinel);
+ }
+ }
+
+ // Sticky header detection - observe all current and future headings
+ const stickyObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ entry.target.classList.toggle("is-stuck", !entry.isIntersecting);
+ });
+ },
+ { rootMargin: "-1px 0px 0px 0px", threshold: 1 },
+ );
+
+ // Use MutationObserver to watch for new month headings being added
+ const timelineItems = document.getElementById("timeline-items");
+ if (timelineItems) {
+ const mutationObserver = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ if (node.matches?.(".timeline-heading")) {
+ stickyObserver.observe(node);
+ }
+ const heading = node.querySelector?.(".timeline-heading");
+ if (heading) {
+ stickyObserver.observe(heading);
+ }
+ }
+ });
+ });
+ });
+ mutationObserver.observe(timelineItems, { childList: true });
+ }
+
+ // Observe existing headings
+ document.querySelectorAll(".timeline-heading").forEach((heading) => {
+ stickyObserver.observe(heading);
+ });
+
{% endblock %}
diff --git a/templates/partials/bag_card.html b/templates/partials/bag_card.html
index 0260285..c5ebb22 100644
--- a/templates/partials/bag_card.html
+++ b/templates/partials/bag_card.html
@@ -1,27 +1,41 @@
{% import "partials/icons.html" as icons %}
{% macro card(bag, is_authenticated) %}
-
-
-
{{ bag.roast_name }}
-
{{ bag.roaster_name }}
-
-
-
- {% if is_authenticated %}
-
-
- {{ icons::beaker("h-4 w-4") }}
- Brew
-
-
- {% endif %}
-
+
+
+
+
+ {{ bag.remaining }} / {{ bag.amount }}
+
+
+
+ {% if is_authenticated %}
+
+
+ {{ icons::beaker("h-4 w-4") }} Brew
+
+
+ {% endif %}
+
{% endmacro %}
diff --git a/templates/partials/brew_card.html b/templates/partials/brew_card.html
index ec204f0..7b3a691 100644
--- a/templates/partials/brew_card.html
+++ b/templates/partials/brew_card.html
@@ -1,25 +1,43 @@
{% import "partials/icons.html" as icons %}
{% macro card(brew, is_authenticated) %}
-
-
-
{{ brew.roast_name }}
-
{{ brew.relative_date_label }}
-
-
-
{{ brew.coffee_weight }} · {{ brew.water_volume }}{% if let Some(bt) = brew.brew_time %} · {{ bt }}{% endif %}
-
{{ brew.grinder_model }} · {{ brew.grind_setting }}
-
{{ brew.brewer_name }}
- {% if let Some(fp) = brew.filter_paper_name %}
{{ fp }}
{% endif %}
- {% if !brew.quick_notes.is_empty() %}
{{ brew.quick_notes_label }}
{% endif %}
-
- {% if is_authenticated %}
-
-
- {{ icons::beaker("h-4 w-4") }}
- Brew Again
-
-
- {% endif %}
-
+
+
+
{{ brew.roast_name }}
+
+ {{ brew.relative_date_label }}
+
+
+
+
+ {{ brew.coffee_weight }} ·
+ {{ brew.water_volume }}{% if let Some(bt) = brew.brew_time %}
+ · {{ bt }}
+ {% endif %}
+
+
{{ brew.grinder_model }} · {{ brew.grind_setting }}
+
{{ brew.brewer_name }}
+ {% if let Some(fp) = brew.filter_paper_name %}
{{ fp }}
{% endif %}
+ {% if !brew.quick_notes.is_empty() %}
+
{{ brew.quick_notes_label }}
+ {% endif %}
+
+ {% if is_authenticated %}
+
+
+ {{ icons::beaker("h-4 w-4") }} Brew Again
+
+
+ {% endif %}
+
{% endmacro %}
diff --git a/templates/partials/detail_cards.html b/templates/partials/detail_cards.html
index b848136..42da7af 100644
--- a/templates/partials/detail_cards.html
+++ b/templates/partials/detail_cards.html
@@ -1,167 +1,208 @@
{% import "partials/icons.html" as icons %}
{% macro share_button() %}
-
- {{ icons::clipboard("h-4 w-4 shrink-0") }}
- {{ icons::check("h-4 w-4 shrink-0") }}
- Share
-
+
+ {{ icons::clipboard("h-4 w-4 shrink-0") }}
+ {{ icons::check("h-4 w-4 shrink-0") }}
+ Share
+
{% endmacro %}
{% macro share_script() %}
-
+
{% endmacro %}
{% macro coffee_card(roast_name, roaster_name, origin, origin_flag, region, producer, process, tasting_notes) %}
-
-
Coffee
-
-
-
Roast
- {{ roast_name }}
-
-
-
Roaster
- {{ roaster_name }}
-
- {% if origin != "\u{2014}" %}
-
-
Origin
-
- {% if !origin_flag.is_empty() %}{{ origin_flag }} {% endif %}{{ origin }}
-
-
+
+
Coffee
+
+
+
Roast
+ {{ roast_name }}
+
+
+
Roaster
+ {{ roaster_name }}
+
+ {% if origin != "\u{2014}" %}
+
+
Origin
+
+ {% if !origin_flag.is_empty() %}{{ origin_flag }}{% endif %}{{ origin }}
+
+
+ {% endif %}
+ {% if region != "\u{2014}" %}
+
+
Region
+ {{ region }}
+
+ {% endif %}
+ {% if producer != "\u{2014}" %}
+
+
Producer
+ {{ producer }}
+
+ {% endif %}
+ {% if process != "\u{2014}" %}
+
+
Process
+ {{ process }}
+
+ {% endif %}
+
+ {% if !tasting_notes.is_empty() %}
+
+ {% for note in tasting_notes %}
+ {{ note.label }}
+ {% endfor %}
+
{% endif %}
- {% if region != "\u{2014}" %}
-
-
Region
- {{ region }}
-
- {% endif %}
- {% if producer != "\u{2014}" %}
-
-
Producer
- {{ producer }}
-
- {% endif %}
- {% if process != "\u{2014}" %}
-
-
Process
- {{ process }}
-
- {% endif %}
-
- {% if !tasting_notes.is_empty() %}
-
- {% for note in tasting_notes %}
- {{ note.label }}
- {% endfor %}
- {% endif %}
-
{% endmacro %}
{% macro map_with_legend_1(map_countries, map_max, label1, opacity1) %}
-{% if !map_countries.is_empty() %}
-
-
-
-
- {{ label1 }}
-
-
-
-{% endif %}
+ {% if !map_countries.is_empty() %}
+
+
+
+
+
+ {{ label1 }}
+
+
+
+ {% endif %}
{% endmacro %}
{% macro map_with_legend_2(map_countries, map_max, label1, opacity1, label2, opacity2) %}
-{% if !map_countries.is_empty() %}
-
-
-
-
- {{ label1 }}
-
-
- {{ label2 }}
-
-
-
-{% endif %}
+ {% if !map_countries.is_empty() %}
+
+
+
+
+
+ {{ label1 }}
+
+
+
+ {{ label2 }}
+
+
+
+ {% endif %}
{% endmacro %}
{% macro map_with_legend_3(map_countries, map_max, label1, opacity1, label2, opacity2, label3, opacity3) %}
-{% if !map_countries.is_empty() %}
-
-
-
-
- {{ label1 }}
-
-
- {{ label2 }}
-
-
- {{ label3 }}
-
-
-
-{% endif %}
+ {% if !map_countries.is_empty() %}
+
+
+
+
+
+ {{ label1 }}
+
+
+
+ {{ label2 }}
+
+
+
+ {{ label3 }}
+
+
+
+ {% endif %}
{% endmacro %}
{% macro roaster_card(name, country, country_flag, city, homepage) %}
-
-
Roaster
-
-
-
Name
- {{ name }}
-
-
-
Country
-
- {% if !country_flag.is_empty() %}{{ country_flag }} {% endif %}{{ country }}
-
-
- {% if let Some(c) = city %}
-
-
City
- {{ c }}
-
- {% endif %}
- {% if let Some(url) = homepage %}
-
- {% endif %}
-
-
+
+
Roaster
+
+
+
Name
+ {{ name }}
+
+
+
Country
+
+ {% if !country_flag.is_empty() %}{{ country_flag }}{% endif %}{{ country }}
+
+
+ {% if let Some(c) = city %}
+
+
City
+ {{ c }}
+
+ {% endif %}
+ {% if let Some(url) = homepage %}
+
+ {% endif %}
+
+
{% endmacro %}
diff --git a/templates/partials/forms/scan_result_form.html b/templates/partials/forms/scan_result_form.html
index aa8980f..a542c3f 100644
--- a/templates/partials/forms/scan_result_form.html
+++ b/templates/partials/forms/scan_result_form.html
@@ -1,150 +1,270 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
Roaster
-
-
- {{ icons::fire("h-4 w-4 text-accent shrink-0") }}
-
-
-
-
{{ icons::pencil("h-3 w-3") }} Change
-
-
-
-
+
+
+
Roaster
+
+
+ {{ icons::fire("h-4 w-4 text-accent shrink-0") }}
+
+
+
+
+ {{ icons::pencil("h-3 w-3") }} Change
+
+
+
+
+
-
-
-
Roast
-
-
- {{ icons::coffee_bean("h-4 w-4 text-accent shrink-0") }}
-
-
-
-
{{ icons::pencil("h-3 w-3") }} Change
-
-
-
-
+
+
+
Roast
+
+
+ {{ icons::coffee_bean("h-4 w-4 text-accent shrink-0") }}
+
+
+
+
+ {{ icons::pencil("h-3 w-3") }} Change
+
+
+
+
+
-
-
-
- {{ icons::spinner("h-5 w-5") }}
- Saving…
-
-
-
- {{ icons::x_mark("h-4 w-4") }} Cancel
-
-
-
- {{ icons::plus("h-4 w-4") }} Save Roaster & Roast
-
-
-
- {{ icons::plus("h-4 w-4") }} Save Roast
-
-
-
- {{ icons::bag("h-4 w-4") }} Open Bag
-
-
-
- This roast already exists. Check “Open a bag” above to add a new bag.
-
-
+
+
+
+ {{ icons::spinner("h-5 w-5") }} Saving…
+
+
+
+ {{ icons::x_mark("h-4 w-4") }} Cancel
+
+
+
+ {{ icons::plus("h-4 w-4") }} Save Roaster & Roast
+
+
+
+ {{ icons::plus("h-4 w-4") }} Save Roast
+
+
+
+ {{ icons::bag("h-4 w-4") }} Open Bag
+
+
+
+ This roast already exists. Check “Open a bag” above to add a new
+ bag.
+
+
diff --git a/templates/partials/histogram.html b/templates/partials/histogram.html
index 4f465e8..3b2cdb8 100644
--- a/templates/partials/histogram.html
+++ b/templates/partials/histogram.html
@@ -1,31 +1,47 @@
{% macro bar_chart(items, max_count) %}
-
-
- {% for item in items %}
-
-
{{ item.0 }}
-
-
{{ item.1 }}
+
+
+ {% for item in items %}
+
+
{{ item.0 }}
+
+
{{ item.1 }}
+
+ {% endfor %}
- {% endfor %}
-
{% endmacro %}
{% macro bar_chart_weight(items, max_weight) %}
-
-
- {% for item in items %}
-
-
{{ item.0 }}
-
-
{{ item.2 }}
+
+
+ {% for item in items %}
+
+
{{ item.0 }}
+
+
{{ item.2 }}
+
+ {% endfor %}
- {% endfor %}
-
{% endmacro %}
diff --git a/templates/partials/icons.html b/templates/partials/icons.html
index 4a49983..e8003d6 100644
--- a/templates/partials/icons.html
+++ b/templates/partials/icons.html
@@ -1,218 +1,525 @@
{% macro plus(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro camera(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro spinner(class) %}
-
-
-
-
+
+
+
+
{% endmacro %}
{% macro delete(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro external_link(class) %}
-
-
-
-
+
+
+
+
{% endmacro %}
{% macro location(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro map(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro refresh(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro bag(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro cup(class) %}
-
-
-
-
+
+
+
+
{% endmacro %}
{% macro arrow_up(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro arrow_down_tray(class) %}
-
-
-
-
+
+
+
+
{% endmacro %}
{% macro arrow_up_tray(class) %}
-
-
-
-
+
+
+
+
{% endmacro %}
{% macro plus_circle(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro logout(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro beaker(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro coffee_bean(class) %}
-
-
-
-
+
+
+
+
{% endmacro %}
{% macro fire(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro grinder(class) %}
-
-
-
-
+
+
+
+
{% endmacro %}
-
-
{% macro ellipsis_vertical(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro x_circle(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro chevron_down(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro chevron_up(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro user(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro sun(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro moon(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro check_circle(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro key(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro terminal(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro pencil(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro clipboard(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro check(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro x_mark(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro chevron_left(class) %}
-
-
-
+
+
+
{% endmacro %}
{% macro chevron_right(class) %}
-
-
-
+
+
+
{% endmacro %}
diff --git a/templates/partials/lists/bag_list.html b/templates/partials/lists/bag_list.html
index 7c9c3e4..3bf4f09 100644
--- a/templates/partials/lists/bag_list.html
+++ b/templates/partials/lists/bag_list.html
@@ -3,102 +3,155 @@
{% if bags.items.is_empty() && !navigator.has_search() %}
-
-
- {% if is_authenticated %}
- No bags added yet. Click Add to create the first bag.
- {% else %}
- No bags added yet.
- {% endif %}
-
-
- {% else %}
-
- {{ table::search_header(navigator, "#bag-list") }}
-
-
-
-
- {{ table::sortable_header("Added", "created-at", navigator, "#bag-list") }}
- {{ table::sortable_header("Roaster", "roaster", navigator, "#bag-list") }}
- {{ table::sortable_header("Roast", "roast", navigator, "#bag-list") }}
- {{ table::sortable_header("Status", "status", navigator, "#bag-list") }}
- {{ table::sortable_header("Finished", "finished-at", navigator, "#bag-list") }}
-
-
-
-
- {% for bag in bags.items %}
-
-
- {{ bag.created_date }}
- {{ bag.created_time }}
-
-
- {{ bag.roaster_name }}
-
-
- {{ bag.roast_name }}
-
-
- {% if bag.closed %}
- Closed
- {% else %}
-
-
-
{{ bag.remaining }} / {{ bag.amount }}
-
- {% endif %}
-
- {% if !bag.closed %}
-
-
- {{ bag.remaining }} / {{ bag.amount }}
-
- {% endif %}
-
- {{ bag.finished_date }}
-
- {% if bag.closed %}
-
- {{ bag.finished_date }}
-
- {% endif %}
-
-
-
-
- {% endfor %}
-
-
+
+
+ {% if is_authenticated %}
+ No bags added yet. Click Add to create the first bag.
+ {% else %}
+ No bags added yet.
+ {% endif %}
+
- {% if bags.items.is_empty() %}
-
No bags match the search.
- {% endif %}
- {{ table::pagination_header(bags, navigator, "#bag-list") }}
- {% if bags.has_next() %}
-
- {% endif %}
-
+ {% else %}
+
+ {{ table::search_header(navigator, "#bag-list") }}
+
+
+
+
+ {{ table::sortable_header("Added", "created-at", navigator, "#bag-list") }}
+ {{ table::sortable_header("Roaster", "roaster", navigator, "#bag-list") }}
+ {{ table::sortable_header("Roast", "roast", navigator, "#bag-list") }}
+ {{ table::sortable_header("Status", "status", navigator, "#bag-list") }}
+ {{ table::sortable_header("Finished", "finished-at", navigator, "#bag-list") }}
+
+
+
+
+ {% for bag in bags.items %}
+
+
+ {{ bag.created_date }}
+
+ {{ bag.created_time }}
+
+
+
+ {{ bag.roaster_name }}
+
+
+ {{ bag.roast_name }}
+
+
+ {% if bag.closed %}
+ Closed
+ {% else %}
+
+
+
+ {{ bag.remaining }} / {{ bag.amount }}
+
+
+ {% endif %}
+
+ {% if !bag.closed %}
+
+
+
+ {{ bag.remaining }} / {{ bag.amount }}
+
+
+ {% endif %}
+
+ {{ bag.finished_date }}
+
+ {% if bag.closed %}
+
+ {{ bag.finished_date }}
+
+ {% endif %}
+
+
+
+
+ {% endfor %}
+
+
+
+ {% if bags.items.is_empty() %}
+
+ No bags match the search.
+
+ {% endif %}
+ {{ table::pagination_header(bags, navigator, "#bag-list") }}
+ {% if bags.has_next() %}
+
+ {% endif %}
+
{% endif %}
diff --git a/templates/partials/lists/brew_list.html b/templates/partials/lists/brew_list.html
index 2a79710..5cd571a 100644
--- a/templates/partials/lists/brew_list.html
+++ b/templates/partials/lists/brew_list.html
@@ -3,106 +3,164 @@
{% if brews.items.is_empty() && !navigator.has_search() %}
-
-
- {% if is_authenticated %}
- No brews logged yet. Click Add to log the first brew.
- {% else %}
- No brews logged yet.
- {% endif %}
-
-
+
+
+ {% if is_authenticated %}
+ No brews logged yet. Click Add to log the first brew.
+ {% else %}
+ No brews logged yet.
+ {% endif %}
+
+
{% else %}
-
- {{ table::search_header(navigator, "#brew-list") }}
+
+ {{ table::search_header(navigator, "#brew-list") }}
-
-
-
-
- {{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }}
- Coffee
- Grind
- Recipe
- Brewer
- Notes
-
-
-
-
- {% for brew in brews.items %}
-
+
+
-
- {{ brew.created_date }}
- {{ brew.created_time }}
-
-
- {{ brew.roast_name }}
- {{ brew.roaster_name }}
-
- {{ brew.roaster_name }}
-
- {{ brew.grind_setting }}
- {{ brew.grinder_name }}
-
- {{ brew.grinder_name }}
-
- {{ brew.coffee_weight }} · {{ brew.water_volume }}
- {% if let Some(bt) = brew.brew_time %}{{ bt }} · {% endif %}{{ brew.water_temp }}
-
- {% if let Some(bt) = brew.brew_time %}
- {{ bt }}
- {% endif %}
- {{ brew.water_temp }}
-
- {{ brew.brewer_name }}
- {% if let Some(fp_name) = brew.filter_paper_name %}
- {{ fp_name }}
- {% endif %}
-
- {% if let Some(fp_name) = brew.filter_paper_name %}
- {{ fp_name }}
- {% endif %}
-
- {% if !brew.quick_notes.is_empty() %}
-
- {% for qn in brew.quick_notes %}
- {{ qn.label }}
- {% endfor %}
-
- {% else %}
- No Notes
- {% endif %}
-
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
-
- {% endfor %}
-
-
-
+
+ {{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }}
+ Coffee
+ Grind
+ Recipe
+ Brewer
+ Notes
+
+
+
+
+ {% for brew in brews.items %}
+
+
+ {{ brew.created_date }}
+
+ {{ brew.created_time }}
+
+
+
+ {{ brew.roast_name }}
+
+ {{ brew.roaster_name }}
+
+
+
+ {{ brew.roaster_name }}
+
+
+ {{ brew.grind_setting }}
+
+ {{ brew.grinder_name }}
+
+
+
+ {{ brew.grinder_name }}
+
+
+ {{ brew.coffee_weight }} · {{ brew.water_volume }}
+
+ {% if let Some(bt) = brew.brew_time %}{{ bt }} ·{% endif %}{{ brew.water_temp }}
+
+
+ {% if let Some(bt) = brew.brew_time %}
+
+ {{ bt }}
+
+ {% endif %}
+
+ {{ brew.water_temp }}
+
+
+ {{ brew.brewer_name }}
+ {% if let Some(fp_name) = brew.filter_paper_name %}
+
+ {{ fp_name }}
+
+ {% endif %}
+
+ {% if let Some(fp_name) = brew.filter_paper_name %}
+
+ {{ fp_name }}
+
+ {% endif %}
+
+ {% if !brew.quick_notes.is_empty() %}
+
+ {% for qn in brew.quick_notes %}
+ {{ qn.label }}
+ {% endfor %}
+
+ {% else %}
+ No Notes
+ {% endif %}
+
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+
+ {% endfor %}
+
+
+
- {% if brews.items.is_empty() %}
-
- No brews match the search.
-
- {% endif %}
+ {% if brews.items.is_empty() %}
+
+ No brews match the search.
+
+ {% endif %}
- {{ table::pagination_header(brews, navigator, "#brew-list") }}
+ {{ table::pagination_header(brews, navigator, "#brew-list") }}
- {% if brews.has_next() %}
-
- {% endif %}
-
+ {% if brews.has_next() %}
+
+ {% endif %}
+
{% endif %}
diff --git a/templates/partials/lists/cafe_list.html b/templates/partials/lists/cafe_list.html
index c21c484..184f57f 100644
--- a/templates/partials/lists/cafe_list.html
+++ b/templates/partials/lists/cafe_list.html
@@ -3,73 +3,119 @@
{% if cafes.items.is_empty() && !navigator.has_search() %}
-
-
- {% if is_authenticated %}
- No cafes added yet. Click Add to add the first cafe.
- {% else %}
- No cafes added yet.
- {% endif %}
-
-
- {% else %}
-
- {{ table::search_header(navigator, "#cafe-list") }}
-
-
-
-
- {{ table::sortable_header("Added", "created-at", navigator, "#cafe-list") }}
- {{ table::sortable_header("Name", "name", navigator, "#cafe-list") }}
- {{ table::sortable_header("Country", "country", navigator, "#cafe-list") }}
- {{ table::sortable_header("City", "city", navigator, "#cafe-list") }}
-
-
-
-
- {% for cafe in cafes.items %}
-
-
- {{ cafe.created_date }}
- {{ cafe.created_time }}
-
-
- {{ cafe.name }}
-
- {% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }}
- {{ cafe.city }}
- {% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }}
- {{ cafe.city }}
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
-
- {% endfor %}
-
-
+
+
+ {% if is_authenticated %}
+ No cafes added yet. Click Add to add the first cafe.
+ {% else %}
+ No cafes added yet.
+ {% endif %}
+
- {% if cafes.items.is_empty() %}
-
No cafes match the search.
- {% endif %}
- {{ table::pagination_header(cafes, navigator, "#cafe-list") }}
- {% if cafes.has_next() %}
-
- {% endif %}
-
+ {% else %}
+
+ {{ table::search_header(navigator, "#cafe-list") }}
+
+
+
+
+ {{ table::sortable_header("Added", "created-at", navigator, "#cafe-list") }}
+ {{ table::sortable_header("Name", "name", navigator, "#cafe-list") }}
+ {{ table::sortable_header("Country", "country", navigator, "#cafe-list") }}
+ {{ table::sortable_header("City", "city", navigator, "#cafe-list") }}
+
+
+
+
+ {% for cafe in cafes.items %}
+
+
+ {{ cafe.created_date }}
+
+ {{ cafe.created_time }}
+
+
+
+ {{ cafe.name }}
+
+
+ {% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }}
+
+
+ {{ cafe.city }}
+
+
+ {% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }}
+
+
+ {{ cafe.city }}
+
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+
+ {% endfor %}
+
+
+
+ {% if cafes.items.is_empty() %}
+
+ No cafes match the search.
+
+ {% endif %}
+ {{ table::pagination_header(cafes, navigator, "#cafe-list") }}
+ {% if cafes.has_next() %}
+
+ {% endif %}
+
{% endif %}
diff --git a/templates/partials/lists/cup_list.html b/templates/partials/lists/cup_list.html
index 3347dbd..857c243 100644
--- a/templates/partials/lists/cup_list.html
+++ b/templates/partials/lists/cup_list.html
@@ -3,74 +3,125 @@
{% if cups.items.is_empty() && !navigator.has_search() %}
-
-
- {% if is_authenticated %}
- No cups added yet. Click Add to add the first cup.
- {% else %}
- No cups added yet.
- {% endif %}
-
-
- {% else %}
-
- {{ table::search_header(navigator, "#cup-list") }}
-
-
-
-
- {{ table::sortable_header("Added", "created-at", navigator, "#cup-list") }}
- {{ table::sortable_header("Roast", "roast", navigator, "#cup-list") }}
- {{ table::sortable_header("Roaster", "roaster", navigator, "#cup-list") }}
- {{ table::sortable_header("Cafe", "cafe", navigator, "#cup-list") }}
- {{ table::sortable_header("City", "city", navigator, "#cup-list") }}
-
-
-
-
- {% for cup in cups.items %}
-
-
- {{ cup.created_date }}
- {{ cup.created_time }}
-
-
- {{ cup.roast_name }}
-
- {{ cup.roaster_name }}
- {{ cup.roast_name }}
- {{ cup.roaster_name }}
-
- {{ cup.cafe_name }}
-
- {{ cup.cafe_city }}
- {{ cup.cafe_city }}
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
-
- {% endfor %}
-
-
+
+
+ {% if is_authenticated %}
+ No cups added yet. Click Add to add the first cup.
+ {% else %}
+ No cups added yet.
+ {% endif %}
+
- {% if cups.items.is_empty() %}
-
No cups match the search.
- {% endif %}
- {{ table::pagination_header(cups, navigator, "#cup-list") }}
- {% if cups.has_next() %}
-
- {% endif %}
-
+ {% else %}
+
+ {{ table::search_header(navigator, "#cup-list") }}
+
+
+
+
+ {{ table::sortable_header("Added", "created-at", navigator, "#cup-list") }}
+ {{ table::sortable_header("Roast", "roast", navigator, "#cup-list") }}
+ {{ table::sortable_header("Roaster", "roaster", navigator, "#cup-list") }}
+ {{ table::sortable_header("Cafe", "cafe", navigator, "#cup-list") }}
+ {{ table::sortable_header("City", "city", navigator, "#cup-list") }}
+
+
+
+
+ {% for cup in cups.items %}
+
+
+ {{ cup.created_date }}
+
+ {{ cup.created_time }}
+
+
+
+ {{ cup.roast_name }}
+
+
+ {{ cup.roaster_name }}
+
+
+ {{ cup.roast_name }}
+
+
+ {{ cup.roaster_name }}
+
+
+ {{ cup.cafe_name }}
+
+
+ {{ cup.cafe_city }}
+
+
+ {{ cup.cafe_city }}
+
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+
+ {% endfor %}
+
+
+
+ {% if cups.items.is_empty() %}
+
+ No cups match the search.
+
+ {% endif %}
+ {{ table::pagination_header(cups, navigator, "#cup-list") }}
+ {% if cups.has_next() %}
+
+ {% endif %}
+
{% endif %}
diff --git a/templates/partials/lists/gear_list.html b/templates/partials/lists/gear_list.html
index a8566e4..31830e0 100644
--- a/templates/partials/lists/gear_list.html
+++ b/templates/partials/lists/gear_list.html
@@ -3,71 +3,106 @@
{% if gear.items.is_empty() && !navigator.has_search() %}
-
-
- {% if is_authenticated %}
- No gear added yet. Click Add to add the first piece of equipment.
- {% else %}
- No gear added yet.
- {% endif %}
-
-
- {% else %}
-
- {{ table::search_header(navigator, "#gear-list") }}
-
-
-
-
-
- {{ table::sortable_header("Added", "created-at", navigator, "#gear-list") }}
- {{ table::sortable_header("Category", "category", navigator, "#gear-list") }}
- {{ table::sortable_header("Make", "make", navigator, "#gear-list") }}
- {{ table::sortable_header("Model", "model", navigator, "#gear-list") }}
-
-
-
-
- {% for item in gear.items %}
-
-
- {{ item.created_date }}
- {{ item.created_time }}
-
- {{ item.make }} {{ item.model }}
-
- {{ item.category_label }}
-
-
- {{ item.make }}
-
-
- {{ item.model }}
-
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
-
- {% endfor %}
-
-
+
+
+ {% if is_authenticated %}
+ No gear added yet. Click Add to add the first piece of equipment.
+ {% else %}
+ No gear added yet.
+ {% endif %}
+
- {% if gear.items.is_empty() %}
-
No gear matches the search.
- {% endif %}
- {{ table::pagination_header(gear, navigator, "#gear-list") }}
- {% if gear.has_next() %}
-
- {% endif %}
-
+ {% else %}
+
+ {{ table::search_header(navigator, "#gear-list") }}
+
+
+
+
+
+ {{ table::sortable_header("Added", "created-at", navigator, "#gear-list") }}
+ {{ table::sortable_header("Category", "category", navigator, "#gear-list") }}
+ {{ table::sortable_header("Make", "make", navigator, "#gear-list") }}
+ {{ table::sortable_header("Model", "model", navigator, "#gear-list") }}
+
+
+
+
+ {% for item in gear.items %}
+
+
+ {{ item.created_date }}
+
+ {{ item.created_time }}
+
+
+
+ {{ item.make }} {{ item.model }}
+
+
+ {{ item.category_label }}
+
+
+ {{ item.make }}
+
+
+ {{ item.model }}
+
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+
+ {% endfor %}
+
+
+
+ {% if gear.items.is_empty() %}
+
+ No gear matches the search.
+
+ {% endif %}
+ {{ table::pagination_header(gear, navigator, "#gear-list") }}
+ {% if gear.has_next() %}
+
+ {% endif %}
+
{% endif %}
diff --git a/templates/partials/lists/roast_list.html b/templates/partials/lists/roast_list.html
index 1e5f173..e673f06 100644
--- a/templates/partials/lists/roast_list.html
+++ b/templates/partials/lists/roast_list.html
@@ -3,92 +3,133 @@
{% if roasts.items.is_empty() && !navigator.has_search() %}
-
-
- {% if is_authenticated %}
- No roasts added yet. Click Add to create the first roast.
- {% else %}
- No roasts added yet.
- {% endif %}
-
-
- {% else %}
-
- {{ table::search_header(navigator, "#roast-list") }}
-
-
-
-
- {{ table::sortable_header("Added", "created-at", navigator, "#roast-list") }}
- {{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
- {{ table::sortable_header("Origin", "origin", navigator, "#roast-list") }}
- Tasting Notes
-
-
-
-
- {% for roast in roasts.items %}
-
-
- {{ roast.created_date }}
- {{ roast.created_time }}
-
-
- {{ roast.name }}
- {{ roast.roaster_label }}
-
- {{ roast.roaster_label }}
-
- {% if !roast.origin_flag.is_empty() %}{{ roast.origin_flag }} {% endif %}{{ roast.origin }}
- {% if !roast.producer.is_empty() %}
- {{ roast.producer }}
- {% endif %}
-
- {% if !roast.producer.is_empty() %}
- {{ roast.producer }}
- {% endif %}
-
- {% if !roast.tasting_notes.is_empty() %}
-
- {% for note in roast.tasting_notes %}
- {{ note.label }}
- {% endfor %}
-
- {% else %}
- No Notes
- {% endif %}
-
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
-
- {% endfor %}
-
-
+
+
+ {% if is_authenticated %}
+ No roasts added yet. Click Add to create the first roast.
+ {% else %}
+ No roasts added yet.
+ {% endif %}
+
- {% if roasts.items.is_empty() %}
-
No roasts match the search.
- {% endif %}
- {{ table::pagination_header(roasts, navigator, "#roast-list") }}
- {% if roasts.has_next() %}
-
- {% endif %}
-
+ {% else %}
+
+ {{ table::search_header(navigator, "#roast-list") }}
+
+
+
+
+ {{ table::sortable_header("Added", "created-at", navigator, "#roast-list") }}
+ {{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
+ {{ table::sortable_header("Origin", "origin", navigator, "#roast-list") }}
+ Tasting Notes
+
+
+
+
+ {% for roast in roasts.items %}
+
+
+ {{ roast.created_date }}
+
+ {{ roast.created_time }}
+
+
+
+ {{ roast.name }}
+
+ {{ roast.roaster_label }}
+
+
+
+ {{ roast.roaster_label }}
+
+
+ {% if !roast.origin_flag.is_empty() %}{{ roast.origin_flag }}{% endif %}{{ roast.origin }}
+ {% if !roast.producer.is_empty() %}
+
+ {{ roast.producer }}
+
+ {% endif %}
+
+ {% if !roast.producer.is_empty() %}
+
+ {{ roast.producer }}
+
+ {% endif %}
+
+ {% if !roast.tasting_notes.is_empty() %}
+
+ {% for note in roast.tasting_notes %}
+ {{ note.label }}
+ {% endfor %}
+
+ {% else %}
+ No Notes
+ {% endif %}
+
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+
+ {% endfor %}
+
+
+
+ {% if roasts.items.is_empty() %}
+
+ No roasts match the search.
+
+ {% endif %}
+ {{ table::pagination_header(roasts, navigator, "#roast-list") }}
+ {% if roasts.has_next() %}
+
+ {% endif %}
+
{% endif %}
diff --git a/templates/partials/lists/roaster_list.html b/templates/partials/lists/roaster_list.html
index 9114bf5..fbab16b 100644
--- a/templates/partials/lists/roaster_list.html
+++ b/templates/partials/lists/roaster_list.html
@@ -3,75 +3,121 @@
{% if roasters.items.is_empty() && !navigator.has_search() %}
-
-
- {% if is_authenticated %}
- No roasters added yet. Click Add to create the first roaster.
- {% else %}
- No roasters added yet.
- {% endif %}
-
-
- {% else %}
-
- {{ table::search_header(navigator, "#roaster-list") }}
-
-
-
-
- {{ table::sortable_header("Added", "created-at", navigator, "#roaster-list") }}
- {{ table::sortable_header("Name", "name", navigator, "#roaster-list") }}
- {{ table::sortable_header("Country", "country", navigator, "#roaster-list") }}
- {{ table::sortable_header("City", "city", navigator, "#roaster-list") }}
-
-
-
-
- {% for roaster in roasters.items %}
-
-
- {{ roaster.created_date }}
- {{ roaster.created_time }}
-
-
- {{ roaster.name }}
-
- {% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}
- {% if !roaster.city.is_empty() %}
- {{ roaster.city }}
- {% endif %}
- {% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}
- {{ roaster.city }}
-
-
- {{ icons::chevron_right("h-5 w-5") }}
-
-
-
- {% endfor %}
-
-
+
+
+ {% if is_authenticated %}
+ No roasters added yet. Click Add to create the first roaster.
+ {% else %}
+ No roasters added yet.
+ {% endif %}
+
- {% if roasters.items.is_empty() %}
-
No roasters match the search.
- {% endif %}
- {{ table::pagination_header(roasters, navigator, "#roaster-list") }}
- {% if roasters.has_next() %}
-
- {% endif %}
-
+ {% else %}
+
+ {{ table::search_header(navigator, "#roaster-list") }}
+
+
+
+
+ {{ table::sortable_header("Added", "created-at", navigator, "#roaster-list") }}
+ {{ table::sortable_header("Name", "name", navigator, "#roaster-list") }}
+ {{ table::sortable_header("Country", "country", navigator, "#roaster-list") }}
+ {{ table::sortable_header("City", "city", navigator, "#roaster-list") }}
+
+
+
+
+ {% for roaster in roasters.items %}
+
+
+ {{ roaster.created_date }}
+
+ {{ roaster.created_time }}
+
+
+
+ {{ roaster.name }}
+
+
+ {% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }}
+
+ {% if !roaster.city.is_empty() %}
+
+ {{ roaster.city }}
+
+ {% endif %}
+
+ {% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }}
+
+
+ {{ roaster.city }}
+
+
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
+
+
+ {% endfor %}
+
+
+
+ {% if roasters.items.is_empty() %}
+
+ No roasters match the search.
+
+ {% endif %}
+ {{ table::pagination_header(roasters, navigator, "#roaster-list") }}
+ {% if roasters.has_next() %}
+
+ {% endif %}
+
{% endif %}
diff --git a/templates/partials/lists/table.html b/templates/partials/lists/table.html
index 62ec61e..519b16a 100644
--- a/templates/partials/lists/table.html
+++ b/templates/partials/lists/table.html
@@ -1,98 +1,145 @@
{% macro search_header(navigator, target_selector) %}
-
-
-
+
+
+
{% endmacro %}
{% macro pagination_header(items, navigator, target_selector) %}
-