From 1a71fb415822153e362b205e43690a3bef89b44f Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 9 Feb 2026 13:40:05 +0000 Subject: [PATCH] chore: autoformat templates with prettier --- CLAUDE.md | 39 +- static/css/input.css | 55 +- static/js/components/chip-scroll.js | 56 +- static/js/components/donut-chart.js | 70 +- static/js/components/photo-capture.js | 52 +- static/js/components/searchable-select.js | 209 +- static/js/components/world-map.js | 211 +- static/js/webauthn.js | 37 +- templates/base.html | 197 +- templates/pages/add.html | 1747 +++++++++++------ templates/pages/admin.html | 918 +++++---- templates/pages/bag.html | 190 +- templates/pages/brew.html | 195 +- templates/pages/cafe.html | 112 +- templates/pages/checkin.html | 669 ++++--- templates/pages/cli_callback.html | 59 +- templates/pages/cup.html | 141 +- templates/pages/data.html | 45 +- templates/pages/gear.html | 86 +- templates/pages/home.html | 866 ++++---- templates/pages/login.html | 133 +- templates/pages/register.html | 179 +- templates/pages/roast.html | 68 +- templates/pages/roaster.html | 118 +- templates/pages/stats.html | 399 ++-- templates/pages/timeline.html | 442 +++-- templates/partials/bag_card.html | 60 +- templates/partials/brew_card.html | 62 +- templates/partials/detail_cards.html | 327 +-- .../partials/forms/scan_result_form.html | 414 ++-- templates/partials/histogram.html | 60 +- templates/partials/icons.html | 535 +++-- templates/partials/lists/bag_list.html | 245 ++- templates/partials/lists/brew_list.html | 248 ++- templates/partials/lists/cafe_list.html | 180 +- templates/partials/lists/cup_list.html | 187 +- templates/partials/lists/gear_list.html | 165 +- templates/partials/lists/roast_list.html | 213 +- templates/partials/lists/roaster_list.html | 184 +- templates/partials/lists/table.html | 203 +- templates/partials/location_search.html | 102 +- templates/partials/nav.html | 158 +- templates/partials/nearby_cafes.html | 47 +- templates/partials/roast_options.html | 2 +- templates/partials/scan_input.html | 51 +- templates/partials/stats_map.html | 124 +- templates/partials/tab_bar.html | 62 +- templates/partials/timeline_chunk.html | 2 +- templates/partials/timeline_month.html | 242 ++- 49 files changed, 6736 insertions(+), 4430 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e70d790..a13c019 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -448,7 +448,11 @@ Template structure: >
- + Take Photo @@ -697,7 +701,13 @@ Label + input pattern: ```html ``` @@ -787,7 +797,9 @@ List partials live in `templates/partials/lists/`. Each follows:
{% if items.is_empty() && !navigator.has_search() %} -
+

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 }}
- + ``` **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 %} -
-
-

Add

-

Add new coffee data manually or by scanning a bag.

-
+
+
+

Add

+

+ Add new coffee data manually or by scanning a bag. +

+
- -
- {% include "partials/tab_bar.html" %} -
+ +
{% include "partials/tab_bar.html" %}
- - - - - - - - - - -
+ +
+ + + + {% 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 %} -
-

Admin

-

Manage passkeys, API tokens, and data.

-
+
+

Admin

+

+ Manage passkeys, API tokens, and data. +

+
- -
-
-
-

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 %} + + {% endif %}
-
- {% if passkeys.len() > 1 %} - - {% 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 %} - - - -
- -
-
-
- - -
-
-
-

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 %} - -
-
- -
- {% endfor %} -
- {% endif %} - - - - -
-
- +
+ {% endfor %} +
+ {% endif %} + + + + + + + +
+ +
-
- + - -
-
-
-

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. +

+
-
- - {{ icons::arrow_down_tray("h-4 w-4") }} - New Backup - - - + - -
- - - - - -
-
- - -{% 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.

-
-
-
- -
+ +
+ + + + +
-
-
+ - + }; + + // --- 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 %} -
-
-

{{ bag.roast_name }}

-

- {{ bag.roaster_name }} · {{ bag.amount }} · Opened {{ bag.created_date }} -

-
-
- -{# ── 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 %} - - {% endif %} - -
-
-{% 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 %} +
+
+

{{ bag.roast_name }}

+

+ {{ bag.roaster_name }} · {{ bag.amount }} · Opened + {{ bag.created_date }} +

+
+
+ + {# ── 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 %} + + {% endif %} + +
+
+ {% 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 %} -
-
-

{{ brew.roast_name }}

-

- {{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }} -

-
-
- -{# ── 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 %} -
- -
-{% 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 %} +
+
+

{{ brew.roast_name }}

+

+ {{ brew.roaster_name }} · Brewed {{ brew.created_date }} at + {{ brew.created_time }} +

+
+
+ + {# ── 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 %} +
+ +
+ {% 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 %} -
-
-

{{ cafe.name }}

-

- {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added {{ cafe.created_date }} -

-
-
- -
-
-

Details

-
-
-
City
-
{{ cafe.city }}
-
-
-
Country
-
- {% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }} -
-
- {% if let Some(url) = cafe.website %} -
-
Website
-
- Visit Website -
-
- {% endif %} -
-
- - {{ detail::map_with_legend_1(cafe.map_countries, cafe.map_max, "Cafe", "") }} -
- -{% if is_authenticated %} -
- -
-{% endif %} +{% block og_description %} + {{ cafe.name }} + — {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} +{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+
+

{{ cafe.name }}

+

+ {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added + {{ cafe.created_date }} +

+
+
+ +
+
+

Details

+
+
+
City
+
{{ cafe.city }}
+
+
+
Country
+
+ {% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }} +
+
+ {% if let Some(url) = cafe.website %} +
+
Website
+
+ Visit Website +
+
+ {% endif %} +
+
+ + {{ detail::map_with_legend_1(cafe.map_countries, cafe.map_max, "Cafe", "") }} +
+ + {% if is_authenticated %} +
+ +
+ {% 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 %} -
-
-

Check In

-

Record a cup of coffee at a cafe.

-
- - -
- -
- - - - - -
-
-
- Cafe -
-
-
- Coffee -
-
-
- Submit -
-
-
- - -
{% 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 %} -
-
-

{{ cup.roast_name }}

-

- {{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }} -

-
-
- -{# ── 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 %} -
-
Website
-
- Visit Website -
-
- {% endif %} -
-
-
- -{% if is_authenticated %} -
- -
-{% 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 %} +
+
+

{{ cup.roast_name }}

+

+ {{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · + {{ cup.created_date }} +

+
+
+ + {# ── 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 %} +
+
Website
+
+ Visit Website +
+
+ {% endif %} +
+
+
+ + {% if is_authenticated %} +
+ +
+ {% 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 %} -
-
-

{{ gear.make }} {{ gear.model }}

-

- {{ gear.category_label }} · Added {{ gear.created_date }} -

-
-
- -
-

Details

-
-
-
Category
-
{{ gear.category_label }}
-
-
-
Make
-
{{ gear.make }}
-
-
-
Model
-
{{ gear.model }}
-
-
-
- -{% if is_authenticated %} -
- -
-{% endif %} +{% block og_description %} + {{ gear.make }} + {{ gear.model }} + — {{ gear.category_label }} +{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+
+

{{ gear.make }} {{ gear.model }}

+

+ {{ gear.category_label }} · Added {{ gear.created_date }} +

+
+
+ +
+

Details

+
+
+
Category
+
{{ gear.category_label }}
+
+
+
Make
+
{{ gear.make }}
+
+
+
Model
+
{{ gear.model }}
+
+
+
+ + {% if is_authenticated %} +
+ +
+ {% 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 %} -
- -
- - - -
- - -
-
- {% include "partials/forms/scan_result_form.html" %} -
-
-
-{% endif %} - - -
-
-

Recent Brews

- View all brews → -
- {% if !recent_brews.is_empty() %} - - -
- {% for brew in recent_brews %} {{ brew_card::card(brew, is_authenticated) }} {% endfor %} -
- -
- {% else %} -
- -
- No brews yet -
-
- {% endif %} -
- - -
-
-

Open Bags

- View all bags → -
- {% if !open_bags.is_empty() %} - - -
- {% for bag in open_bags %} {{ bag_card::card(bag, is_authenticated) }} {% endfor %} -
- -
- {% else %} -
- -
- No open bags -
-
- {% endif %} -
- - -
-
-

Recent Activity

- View full timeline → -
- {% if !recent_events.is_empty() %} - - {% else %} -
- -
- No activity yet -
-
- {% endif %} -
- - -
-
-

Stats

- View all stats → -
- {% if !stats.is_empty() && !stat_cards.is_empty() %} - - - - - - {% else %} -
- -
- No stats yet -
-
- {% endif %} -
- - -
-
-

Data

- View all data → -
- {% if !stats.is_empty() %} - - - - - - {% else %} -
- -
- 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 %} +
+ +
+ + + +
+ + +
+
+ {% include "partials/forms/scan_result_form.html" %} +
+
+
+ {% endif %} + + +
+
+

Recent Brews

+ View all brews → +
+ {% if !recent_brews.is_empty() %} + + +
+ {% for brew in recent_brews %}{{ brew_card::card(brew, is_authenticated) }}{% endfor %} +
+ +
+ {% else %} +
+ +
+ No brews yet +
+
+ {% endif %} +
+ + +
+
+

Open Bags

+ View all bags → +
+ {% if !open_bags.is_empty() %} + + +
+ {% for bag in open_bags %}{{ bag_card::card(bag, is_authenticated) }}{% endfor %} +
+ +
+ {% else %} +
+ +
+ No open bags +
+
+ {% endif %} +
+ + +
+
+

Recent Activity

+ View full timeline → +
+ {% if !recent_events.is_empty() %} + + {% else %} +
+ +
+ No activity yet +
+
+ {% endif %} +
+ + +
+
+

Stats

+ View all stats → +
+ {% if !stats.is_empty() && !stat_cards.is_empty() %} + + + + + + {% else %} +
+ +
+ No stats yet +
+
+ {% endif %} +
+ + +
+
+

Data

+ View all data → +
+ {% if !stats.is_empty() %} + + + + + + {% else %} +
+ +
+ 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. +

- + - + -
- +
+ -
-
- + {% 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. +

- + - + -
- +
+ - + - + -
-
-
- + {% 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 %} -
-
-

{{ roast.name }}

-

- {{ roast.roaster_name }} · {{ roast.origin_flag }} {{ roast.origin }} · Added {{ roast.created_date }} -

-
-
- -
- {{ 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 %} -
- -
-{% endif %} +{% block og_description %} + {{ roast.name }} + by {{ roast.roaster_name }} — {{ roast.origin }} +{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+
+

{{ roast.name }}

+

+ {{ roast.roaster_name }} · {{ roast.origin_flag }} {{ roast.origin }} · + Added {{ roast.created_date }} +

+
+
+ +
+ {{ 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 %} +
+ +
+ {% 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 %} -
-
-

{{ roaster.name }}

-

- {{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %} · {{ c }}{% endif %} · Added {{ roaster.created_date }} -

-
-
- -
-
-

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 %} -
-
Website
-
- Visit Website -
-
- {% endif %} -
-
- - {{ detail::map_with_legend_1(roaster.map_countries, roaster.map_max, "Roaster", "") }} -
- -{% if is_authenticated %} -
- -
-{% 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 %} +
+
+

{{ roaster.name }}

+

+ {{ roaster.country_flag }} + {{ roaster.country }}{% if let Some(c) = roaster.city %}· {{ c }}{% endif %} + · Added {{ roaster.created_date }} +

+
+
+ +
+
+

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 %} +
+
Website
+
+ Visit Website +
+
+ {% endif %} +
+
+ + {{ detail::map_with_legend_1(roaster.map_countries, roaster.map_max, "Roaster", "") }} +
+ + {% if is_authenticated %} +
+ +
+ {% 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 %} -
- -
- 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 more events.

- -
-
-
-
+ + +

+ 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() %} - + {% 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 %} -
-
Website
-
- Visit Website -
-
- {% 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 %} +
+
Website
+
+ Visit Website +
+
+ {% 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") }} - - -
- -
-
- -
-

Roaster

-

If this roaster already exists, it will be matched automatically.

-
- - - - -
-
+ +
+

Roaster

+
+
+ {{ icons::fire("h-4 w-4 text-accent shrink-0") }} + + +
+ +
+
+ +
+

Roaster

+

+ If this roaster already exists, it will be matched automatically. +

+
+ + + + +
+
- -
-

Roast

-
-
- {{ icons::coffee_bean("h-4 w-4 text-accent shrink-0") }} - - -
- -
-
- -
-

Roast

-

Details about this specific coffee.

-
- - - - - - -
-
+ +
+

Roast

+
+
+ {{ icons::coffee_bean("h-4 w-4 text-accent shrink-0") }} + + +
+ +
+
+ +
+

Roast

+

+ Details about this specific coffee. +

+
+ + + + + + +
+
-
- -
- -
-
- - -
- - - - - - - - - -
+
+ +
+ +
+
+ + +
+ + + + + + + + + +
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 %} - - - - - - {% if !bag.closed %} - - {% endif %} - - {% if bag.closed %} - - {% endif %} - - - {% endfor %} - -
-
{{ bag.created_date }}
- -
- {{ bag.roaster_name }} - - {{ bag.roast_name }} - - {% if bag.closed %} - Closed - {% else %} -
-
-
-
-
{{ bag.remaining }} / {{ bag.amount }}
-
- {% endif %} -
-
-
-
-
{{ bag.remaining }} / {{ bag.amount }}
-
-
{{ bag.finished_date }}
-
-
{{ bag.finished_date }}
-
-
- - {% if bag.closed %} - Closed - {% endif %} - - - {{ icons::chevron_right("h-5 w-5") }} - -
-
+
+

+ {% 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 %} + + + + + + {% if !bag.closed %} + + {% endif %} + + {% if bag.closed %} + + {% endif %} + + + {% endfor %} + +
+
{{ bag.created_date }}
+ +
+ {{ bag.roaster_name }} + + {{ bag.roast_name }} + + {% if bag.closed %} + Closed + {% else %} +
+
+
+
+
+ {{ bag.remaining }} / {{ bag.amount }} +
+
+ {% endif %} +
+
+
+
+
+ {{ bag.remaining }} / {{ bag.amount }} +
+
+
{{ bag.finished_date }}
+
+
{{ bag.finished_date }}
+
+
+ + {% if bag.closed %} + Closed + {% endif %} + + + {{ icons::chevron_right("h-5 w-5") }} + +
+
+
+ {% 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") }} - - - - - - - - - - {% for brew in brews.items %} - +
CoffeeGrindRecipeBrewerNotes
+ - - - - - - - {% if let Some(bt) = brew.brew_time %} - - {% endif %} - - - {% if let Some(fp_name) = brew.filter_paper_name %} - - {% endif %} - - - - {% endfor %} - -
-
{{ brew.created_date }}
- -
-
{{ brew.roast_name }}
- -
{{ brew.roaster_name }} -
{{ brew.grind_setting }}
- -
{{ brew.grinder_name }} -
{{ brew.coffee_weight }} · {{ brew.water_volume }}
- -
{{ bt }}{{ brew.water_temp }} -
{{ brew.brewer_name }}
- {% if let Some(fp_name) = brew.filter_paper_name %} - - {% endif %} -
{{ fp_name }} - {% 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") }} - -
-
+ + {{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }} + Coffee + Grind + Recipe + Brewer + Notes + + + + + {% for brew in brews.items %} + + +
{{ brew.created_date }}
+ + + +
{{ brew.roast_name }}
+ + + + {{ brew.roaster_name }} + + +
{{ brew.grind_setting }}
+ + + + {{ brew.grinder_name }} + + +
{{ brew.coffee_weight }} · {{ brew.water_volume }}
+ + + {% if let Some(bt) = brew.brew_time %} + + {{ bt }} + + {% endif %} + + {{ brew.water_temp }} + + +
{{ brew.brewer_name }}
+ {% if let Some(fp_name) = brew.filter_paper_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 %} - - - - - - - - - - {% endfor %} - -
-
{{ cafe.created_date }}
- -
- {{ 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") }} - -
+
+

+ {% 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 %} + + + + + + + + + + {% endfor %} + +
+
{{ cafe.created_date }}
+ +
+ {{ 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") }} + +
+
+ {% 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 %} - - - - - - - - - - - - {% endfor %} - -
-
{{ cup.created_date }}
- -
-
{{ 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") }} - -
+
+

+ {% 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 %} + + + + + + + + + + + + {% endfor %} + +
+
{{ cup.created_date }}
+ +
+
{{ 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") }} + +
+
+ {% 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 %} - - - - - - - - - {% endfor %} - -
-
{{ item.created_date }}
- -
{{ item.make }} {{ item.model }} - {{ item.category_label }} - - {{ item.make }} - - {{ item.model }} - - - {{ icons::chevron_right("h-5 w-5") }} - -
+
+

+ {% 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 %} + + + + + + + + + {% endfor %} + +
+
{{ item.created_date }}
+ +
+ {{ item.make }} {{ item.model }} + + {{ item.category_label }} + + {{ item.make }} + + {{ item.model }} + + + {{ icons::chevron_right("h-5 w-5") }} + +
+
+ {% 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") }} - - - - - - {% for roast in roasts.items %} - - - - - - {% if !roast.producer.is_empty() %} - - {% endif %} - - - - {% endfor %} - -
Tasting Notes
-
{{ roast.created_date }}
- -
-
{{ roast.name }}
- -
{{ roast.roaster_label }} - {% if !roast.origin_flag.is_empty() %}{{ roast.origin_flag }} {% endif %}{{ roast.origin }} - {% if !roast.producer.is_empty() %} - - {% endif %} - {{ roast.producer }} - {% 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") }} - -
+
+

+ {% 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") }} + + + + + + {% for roast in roasts.items %} + + + + + + {% if !roast.producer.is_empty() %} + + {% endif %} + + + + {% endfor %} + +
Tasting Notes
+
{{ roast.created_date }}
+ +
+
{{ roast.name }}
+ +
+ {{ roast.roaster_label }} + + {% if !roast.origin_flag.is_empty() %}{{ roast.origin_flag }}{% endif %}{{ roast.origin }} + {% if !roast.producer.is_empty() %} + + {% endif %} + + {{ roast.producer }} + + {% 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") }} + +
+
+ {% 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 %} - - - - - {% if !roaster.city.is_empty() %} - - {% endif %} - - - - - {% endfor %} - -
-
{{ roaster.created_date }}
- -
- {{ roaster.name }} - {% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}{{ roaster.city }}{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}{{ roaster.city }} - - {{ icons::chevron_right("h-5 w-5") }} - -
+
+

+ {% 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 %} + + + + + {% if !roaster.city.is_empty() %} + + {% endif %} + + + + + {% endfor %} + +
+
{{ roaster.created_date }}
+ +
+ {{ roaster.name }} + + {% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }} + + {{ roaster.city }} + + {% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }} + + {{ roaster.city }} + + + {{ icons::chevron_right("h-5 w-5") }} + +
+
+ {% 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) %} -