chore: autoformat templates with prettier

This commit is contained in:
Jon Seager 2026-02-09 13:40:05 +00:00
parent 8d433d8e99
commit 1a71fb4158
No known key found for this signature in database
49 changed files with 6736 additions and 4430 deletions

View file

@ -448,7 +448,11 @@ Template structure:
> >
<input type="hidden" name="image" id="{id}-image" /> <input type="hidden" name="image" id="{id}-image" />
<div data-show="!$_extracting"> <div data-show="!$_extracting">
<brew-photo-capture target-input="{id}-image" target-form="{id}-extract-form" class="..."> <brew-photo-capture
target-input="{id}-image"
target-form="{id}-extract-form"
class="..."
>
Take Photo Take Photo
</brew-photo-capture> </brew-photo-capture>
<input name="prompt" type="text" placeholder="Or describe..." /> <input name="prompt" type="text" placeholder="Or describe..." />
@ -697,7 +701,13 @@ Label + input pattern:
```html ```html
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Field Name *</span> <span class="text-text">Field Name *</span>
<input type="text" name="field" required class="input-field" placeholder="Example" /> <input
type="text"
name="field"
required
class="input-field"
placeholder="Example"
/>
</label> </label>
``` ```
@ -787,7 +797,9 @@ List partials live in `templates/partials/lists/`. Each follows:
<div id="{entity}-list" class="mt-6" data-star-scope="{entity}"> <div id="{entity}-list" class="mt-6" data-star-scope="{entity}">
{% if items.is_empty() && !navigator.has_search() %} {% if items.is_empty() && !navigator.has_search() %}
<div class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"> <div
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
>
<p class="text-center">No {entities} recorded yet.</p> <p class="text-center">No {entities} recorded yet.</p>
</div> </div>
{% else %} {% else %}
@ -797,10 +809,15 @@ List partials live in `templates/partials/lists/`. Each follows:
... ...
</table> </table>
{% if items.is_empty() %} {% if items.is_empty() %}
<div class="p-8 text-center text-text-muted">No {entities} match your search.</div> <div class="p-8 text-center text-text-muted">
{% endif %} {% call table::pagination_header(items, navigator, "#{entity}-list") %} {% if No {entities} match your search.
items.has_next() %} </div>
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> {% endif %} {% call table::pagination_header(items, navigator,
"#{entity}-list") %} {% if items.has_next() %}
<div
class="infinite-scroll-sentinel h-4 md:hidden"
aria-hidden="true"
></div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}
@ -834,14 +851,18 @@ Tables use the `responsive-table` CSS class (card layout on mobile, standard tab
```html ```html
<td data-label="Coffee" class="px-4 py-3 whitespace-nowrap"> <td data-label="Coffee" class="px-4 py-3 whitespace-nowrap">
<div class="font-medium">{{ brew.roast_name }}</div> <div class="font-medium">{{ brew.roast_name }}</div>
<div class="hidden md:block text-xs text-text-muted">{{ brew.roaster_name }}</div> <div class="hidden md:block text-xs text-text-muted">
{{ brew.roaster_name }}
</div>
</td> </td>
``` ```
**Mobile** — separate `<td>` for each sub-field: **Mobile** — separate `<td>` for each sub-field:
```html ```html
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.roaster_name }}</td> <td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">
{{ brew.roaster_name }}
</td>
``` ```
### Pagination & Infinite Scroll ### Pagination & Infinite Scroll

View file

@ -87,7 +87,13 @@
/* ── Typography ────────────────────────────────────────────────── */ /* ── Typography ────────────────────────────────────────────────── */
body { 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 { a {
@ -125,7 +131,9 @@ a {
font-family: inherit; font-family: inherit;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text); 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, input.input-field[type="number"]::-webkit-inner-spin-button,
@ -155,7 +163,9 @@ input.input-field[type="number"] {
background-color: var(--surface); background-color: var(--surface);
color: var(--accent); color: var(--accent);
font-weight: 700; font-weight: 700;
transition: background-color 150ms ease, color 150ms ease; transition:
background-color 150ms ease,
color 150ms ease;
} }
.btn-adjust:hover { .btn-adjust:hover {
@ -343,7 +353,10 @@ input.input-field[type="number"] {
cursor: pointer; cursor: pointer;
border: 1px solid var(--border); border: 1px solid var(--border);
background-color: var(--surface); 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); color: var(--accent);
} }
@ -368,7 +381,9 @@ input.input-field[type="number"] {
border: none; border: none;
background: none; background: none;
color: var(--accent); color: var(--accent);
transition: background-color 150ms ease, color 150ms ease; transition:
background-color 150ms ease,
color 150ms ease;
color: var(--text-secondary); color: var(--text-secondary);
} }
@ -452,7 +467,9 @@ input.input-field[type="number"] {
/* ── Data page ─────────────────────────────────────────────────── */ /* ── Data page ─────────────────────────────────────────────────── */
/* Hide duplicate search inside data page - search is in the tab bar */ /* 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; display: none;
} }

View file

@ -1,5 +1,9 @@
customElements.define("chip-scroll", class extends HTMLElement { customElements.define(
connectedCallback() { this._setup(); } "chip-scroll",
class extends HTMLElement {
connectedCallback() {
this._setup();
}
_setup() { _setup() {
if (this._observer) this._observer.disconnect(); if (this._observer) this._observer.disconnect();
@ -16,7 +20,10 @@ customElements.define("chip-scroll", class extends HTMLElement {
return; return;
} }
btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none"; btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none";
btnR.style.display = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1 ? "flex" : "none"; btnR.style.display =
scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1
? "flex"
: "none";
}; };
scroller.addEventListener("scroll", update, { passive: true }); scroller.addEventListener("scroll", update, { passive: true });
@ -27,4 +34,5 @@ customElements.define("chip-scroll", class extends HTMLElement {
update(); update();
} }
}); },
);

View file

@ -1,19 +1,31 @@
const DONUT_ICONS = { const DONUT_ICONS = {
beaker: '<path fill-rule="evenodd" d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z" clip-rule="evenodd" />', beaker:
grinder: '<path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" /><rect x="5" y="16.5" width="10" height="1.5" rx=".5" />' '<path fill-rule="evenodd" d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z" clip-rule="evenodd" />',
grinder:
'<path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" /><rect x="5" y="16.5" width="10" height="1.5" rx=".5" />',
}; };
const esc = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); const esc = (s) =>
s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
class DonutChart extends HTMLElement { class DonutChart extends HTMLElement {
static get observedAttributes() { static get observedAttributes() {
return ['data-items']; return ["data-items"];
} }
connectedCallback() { connectedCallback() {
requestAnimationFrame(() => this.render()); requestAnimationFrame(() => this.render());
this._themeObserver = new MutationObserver(() => requestAnimationFrame(() => this.render())); this._themeObserver = new MutationObserver(() =>
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); requestAnimationFrame(() => this.render()),
);
this._themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
} }
disconnectedCallback() { disconnectedCallback() {
@ -26,22 +38,34 @@ class DonutChart extends HTMLElement {
} }
render() { render() {
const raw = this.dataset.items || ''; const raw = this.dataset.items || "";
if (!raw) { this.innerHTML = ''; return; } if (!raw) {
this.innerHTML = "";
return;
}
const items = raw.split('|').map(s => { const items = raw
const idx = s.lastIndexOf(':'); .split("|")
.map((s) => {
const idx = s.lastIndexOf(":");
if (idx === -1) return null; if (idx === -1) return null;
const label = s.slice(0, idx).trim(); const label = s.slice(0, idx).trim();
const count = parseInt(s.slice(idx + 1), 10); const count = parseInt(s.slice(idx + 1), 10);
return (label && count > 0) ? { label, count } : null; return label && count > 0 ? { label, count } : null;
}).filter(Boolean); })
.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 total = items.reduce((sum, i) => sum + i.count, 0);
const maxCount = items[0].count; 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 colorFor = (count) => {
const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2); const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2);
return `rgba(${rgb}, ${alpha})`; return `rgba(${rgb}, ${alpha})`;
@ -70,7 +94,7 @@ class DonutChart extends HTMLElement {
}); });
const legend = items.map((item, i) => { const legend = items.map((item, i) => {
const pct = Math.round(item.count / total * 100); const pct = Math.round((item.count / total) * 100);
return `<div style="display:flex;align-items:center;gap:0.5rem"> return `<div style="display:flex;align-items:center;gap:0.5rem">
<span style="flex-shrink:0;width:0.5rem;height:0.5rem;border-radius:9999px;background:${colorFor(item.count)}"></span> <span style="flex-shrink:0;width:0.5rem;height:0.5rem;border-radius:9999px;background:${colorFor(item.count)}"></span>
<span class="text-xs text-text-secondary" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(item.label)}</span> <span class="text-xs text-text-secondary" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(item.label)}</span>
@ -80,19 +104,19 @@ class DonutChart extends HTMLElement {
this.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;gap:1rem"> this.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;gap:1rem">
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="display:block"> <svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="display:block">
${segments.join('')} ${segments.join("")}
${(() => { ${(() => {
const icon = DONUT_ICONS[this.dataset.icon]; const icon = DONUT_ICONS[this.dataset.icon];
if (!icon) return ''; if (!icon) return "";
const s = 24; const s = 24;
return `<svg x="${cx - s / 2}" y="${cy - s / 2}" width="${s}" height="${s}" viewBox="0 0 20 20" class="text-text-muted" fill="currentColor">${icon}</svg>`; return `<svg x="${cx - s / 2}" y="${cy - s / 2}" width="${s}" height="${s}" viewBox="0 0 20 20" class="text-text-muted" fill="currentColor">${icon}</svg>`;
})()} })()}
</svg> </svg>
<div style="display:flex;flex-direction:column;gap:0.375rem;width:100%"> <div style="display:flex;flex-direction:column;gap:0.375rem;width:100%">
${legend.join('')} ${legend.join("")}
</div> </div>
</div>`; </div>`;
} }
} }
customElements.define('donut-chart', DonutChart); customElements.define("donut-chart", DonutChart);

View file

@ -1,4 +1,6 @@
customElements.define("brew-photo-capture", class extends HTMLElement { customElements.define(
"brew-photo-capture",
class extends HTMLElement {
connectedCallback() { connectedCallback() {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
@ -15,11 +17,15 @@ customElements.define("brew-photo-capture", class extends HTMLElement {
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
document.getElementById(this.getAttribute("target-input")).value = reader.result; document.getElementById(this.getAttribute("target-input")).value =
document.getElementById(this.getAttribute("target-form")).requestSubmit(); reader.result;
document
.getElementById(this.getAttribute("target-form"))
.requestSubmit();
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
input.value = ""; input.value = "";
}); });
} }
}); },
);

View file

@ -1,4 +1,6 @@
customElements.define("searchable-select", class extends HTMLElement { customElements.define(
"searchable-select",
class extends HTMLElement {
connectedCallback() { connectedCallback() {
requestAnimationFrame(() => this._setup()); requestAnimationFrame(() => this._setup());
} }
@ -8,7 +10,8 @@ customElements.define("searchable-select", class extends HTMLElement {
this._initialized = true; this._initialized = true;
const name = this.getAttribute("name"); const name = this.getAttribute("name");
const placeholder = this.getAttribute("placeholder") || "Type to search\u2026"; const placeholder =
this.getAttribute("placeholder") || "Type to search\u2026";
const buttons = [...this.querySelectorAll("button")]; const buttons = [...this.querySelectorAll("button")];
const hidden = document.createElement("input"); const hidden = document.createElement("input");
@ -21,7 +24,8 @@ customElements.define("searchable-select", class extends HTMLElement {
search.placeholder = placeholder; search.placeholder = placeholder;
const options = document.createElement("div"); const options = document.createElement("div");
options.className = "hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface"; options.className =
"hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface";
buttons.forEach((btn) => options.appendChild(btn)); buttons.forEach((btn) => options.appendChild(btn));
const searchWrap = document.createElement("div"); const searchWrap = document.createElement("div");
@ -33,8 +37,10 @@ customElements.define("searchable-select", class extends HTMLElement {
const clear = document.createElement("button"); const clear = document.createElement("button");
clear.type = "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.className =
clear.innerHTML = '<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /></svg>'; "absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition";
clear.innerHTML =
'<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /></svg>';
const selectedWrap = document.createElement("div"); const selectedWrap = document.createElement("div");
selectedWrap.className = "relative hidden cursor-pointer"; selectedWrap.className = "relative hidden cursor-pointer";
@ -43,9 +49,13 @@ customElements.define("searchable-select", class extends HTMLElement {
// Block native change events from child inputs so only our // Block native change events from child inputs so only our
// CustomEvent (which carries evt.detail) reaches data-on:change. // CustomEvent (which carries evt.detail) reaches data-on:change.
this.addEventListener("change", (e) => { this.addEventListener(
"change",
(e) => {
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation(); if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
}, true); },
true,
);
this.textContent = ""; this.textContent = "";
this.style.display = "block"; this.style.display = "block";
@ -68,7 +78,9 @@ customElements.define("searchable-select", class extends HTMLElement {
const q = search.value.toLowerCase(); const q = search.value.toLowerCase();
options.classList.toggle("hidden", !q); options.classList.toggle("hidden", !q);
buttons.forEach((btn) => { buttons.forEach((btn) => {
btn.style.display = btn.textContent.toLowerCase().includes(q) ? "" : "none"; btn.style.display = btn.textContent.toLowerCase().includes(q)
? ""
: "none";
}); });
}); });
@ -81,10 +93,16 @@ customElements.define("searchable-select", class extends HTMLElement {
searchWrap.classList.add("hidden"); searchWrap.classList.add("hidden");
selectedWrap.classList.remove("hidden"); selectedWrap.classList.remove("hidden");
this.dispatchEvent(new CustomEvent("change", { this.dispatchEvent(
detail: { value: btn.value, display: btn.dataset.display, data: { ...btn.dataset } }, new CustomEvent("change", {
detail: {
value: btn.value,
display: btn.dataset.display,
data: { ...btn.dataset },
},
bubbles: true, bubbles: true,
})); }),
);
}); });
const doClear = () => { const doClear = () => {
@ -101,4 +119,5 @@ customElements.define("searchable-select", class extends HTMLElement {
selectedWrap.addEventListener("click", doClear); selectedWrap.addEventListener("click", doClear);
} }
}); },
);

File diff suppressed because one or more lines are too long

View file

@ -16,7 +16,10 @@ const bufferToBase64url = (buffer) => {
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[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 // Convert server challenge options to format navigator.credentials expects
@ -26,7 +29,7 @@ const prepareCreationOptions = (options) => {
publicKey.user.id = base64urlToBuffer(publicKey.user.id); publicKey.user.id = base64urlToBuffer(publicKey.user.id);
if (publicKey.excludeCredentials) { if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map((cred) => publicKey.excludeCredentials = publicKey.excludeCredentials.map((cred) =>
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }) Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }),
); );
} }
return options; return options;
@ -37,7 +40,7 @@ const prepareRequestOptions = (options) => {
publicKey.challenge = base64urlToBuffer(publicKey.challenge); publicKey.challenge = base64urlToBuffer(publicKey.challenge);
if (publicKey.allowCredentials) { if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map((cred) => publicKey.allowCredentials = publicKey.allowCredentials.map((cred) =>
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }) Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }),
); );
} }
return options; return options;
@ -67,7 +70,9 @@ const serializeAuthenticationCredential = (credential) => {
authenticatorData: bufferToBase64url(response.authenticatorData), authenticatorData: bufferToBase64url(response.authenticatorData),
clientDataJSON: bufferToBase64url(response.clientDataJSON), clientDataJSON: bufferToBase64url(response.clientDataJSON),
signature: bufferToBase64url(response.signature), 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", { const startResponse = await fetch("/api/v1/webauthn/register/start", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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) { if (!startResponse.ok) {
const status = startResponse.status; const status = startResponse.status;
if (status === 401) throw new Error("Invalid registration token."); 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}).`); throw new Error(`Failed to start registration (HTTP ${status}).`);
} }
@ -106,7 +116,9 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
}); });
if (!finishResponse.ok) { 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(); return finishResponse.json();
@ -120,7 +132,8 @@ const startPasskeyAuthentication = async (queryParams) => {
if (!startResponse.ok) { if (!startResponse.ok) {
const status = startResponse.status; 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}).`); throw new Error(`Failed to start authentication (HTTP ${status}).`);
} }
@ -157,7 +170,9 @@ const addPasskey = async (name) => {
}); });
if (!startResponse.ok) { 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(); const { challenge_id, options } = await startResponse.json();
@ -178,6 +193,8 @@ const addPasskey = async (name) => {
}); });
if (!finishResponse.ok) { 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}).`,
);
} }
}; };

View file

@ -5,26 +5,44 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta <meta
name="description" name="description"
content="{% block description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}" content="{% block description %}
Self-hosted coffee logging — track roasters, roasts, bags, brews, and
gear.
{% endblock %}"
/> />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Brewlog" /> <meta property="og:site_name" content="Brewlog" />
<meta property="og:title" content="{% block og_title %}Brewlog{% endblock %}" /> <meta
<meta property="og:description" content="{% block og_description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}" /> property="og:title"
content="{% block og_title %}Brewlog{% endblock %}"
/>
<meta
property="og:description"
content="{% block og_description %}
Self-hosted coffee logging — track roasters, roasts, bags, brews, and
gear.
{% endblock %}"
/>
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<title>{% block title %}Brewlog{% endblock %}</title> <title>{% block title %}Brewlog{% endblock %}</title>
<link rel="stylesheet" href="/static/css/styles.css" /> <link rel="stylesheet" href="/static/css/styles.css" />
<link rel="icon" id="favicon" type="image/svg+xml" href="/static/favicon-light.svg" /> <link
rel="icon"
id="favicon"
type="image/svg+xml"
href="/static/favicon-light.svg"
/>
<script> <script>
;(() => { (() => {
const stored = localStorage.getItem("theme") const stored = localStorage.getItem("theme");
const isDark = const isDark =
stored === "dark" || (!stored && matchMedia("(prefers-color-scheme: dark)").matches) stored === "dark" ||
(!stored && matchMedia("(prefers-color-scheme: dark)").matches);
if (isDark) { if (isDark) {
document.documentElement.setAttribute("data-theme", "dark") document.documentElement.setAttribute("data-theme", "dark");
document.getElementById("favicon").href = "/static/favicon-dark.svg" document.getElementById("favicon").href = "/static/favicon-dark.svg";
} }
})() })();
</script> </script>
<script <script
type="module" type="module"
@ -38,95 +56,110 @@
{% block head %}{% endblock %} {% block head %}{% endblock %}
<script> <script>
const toggleRow = (event) => { const toggleRow = (event) => {
if (event.target.closest(".card-detail")) return if (event.target.closest(".card-detail")) return;
const row = event.currentTarget const row = event.currentTarget;
row.classList.toggle("expanded") row.classList.toggle("expanded");
row.querySelector(".card-detail")?.classList.toggle("hidden") row.querySelector(".card-detail")?.classList.toggle("hidden");
const r = row.nextElementSibling const r = row.nextElementSibling;
if (r?.classList.contains("detail-row")) r.classList.toggle("hidden") if (r?.classList.contains("detail-row")) r.classList.toggle("hidden");
row.querySelector(".icon-expand")?.classList.toggle("hidden") row.querySelector(".icon-expand")?.classList.toggle("hidden");
row.querySelector(".icon-collapse")?.classList.toggle("hidden") row.querySelector(".icon-collapse")?.classList.toggle("hidden");
} };
</script> </script>
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const mobileQuery = window.matchMedia("(max-width: 767px)") const mobileQuery = window.matchMedia("(max-width: 767px)");
if (!mobileQuery.matches) return if (!mobileQuery.matches) return;
const setupInfiniteScroll = () => { const setupInfiniteScroll = () => {
document.querySelectorAll("[data-infinite-scroll]").forEach((container) => { document
const sentinel = container.querySelector(".infinite-scroll-sentinel") .querySelectorAll("[data-infinite-scroll]")
if (!sentinel || sentinel.dataset.observed) return .forEach((container) => {
sentinel.dataset.observed = "true" const sentinel = container.querySelector(
".infinite-scroll-sentinel",
);
if (!sentinel || sentinel.dataset.observed) return;
sentinel.dataset.observed = "true";
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (!entry.isIntersecting) return if (!entry.isIntersecting) return;
const nextUrl = container.getAttribute("data-next-url") const nextUrl = container.getAttribute("data-next-url");
const targetSelector = container.getAttribute("data-target") const targetSelector =
container.getAttribute("data-target");
if (!nextUrl || !targetSelector) { if (!nextUrl || !targetSelector) {
observer.disconnect() observer.disconnect();
return return;
} }
observer.disconnect() observer.disconnect();
sentinel.remove() sentinel.remove();
fetch(nextUrl, { fetch(nextUrl, {
headers: { "datastar-request": "true" }, headers: { "datastar-request": "true" },
}) })
.then((res) => res.text()) .then((res) => res.text())
.then((html) => { .then((html) => {
const doc = new DOMParser().parseFromString(html, "text/html") const doc = new DOMParser().parseFromString(
const newTarget = doc.querySelector(targetSelector) html,
if (!newTarget) return "text/html",
);
const newTarget = doc.querySelector(targetSelector);
if (!newTarget) return;
// Append new rows to existing tbody // Append new rows to existing tbody
const existingTbody = container.querySelector("tbody") const existingTbody = container.querySelector("tbody");
const newTbody = newTarget.querySelector("tbody") const newTbody = newTarget.querySelector("tbody");
if (existingTbody && newTbody) { if (existingTbody && newTbody) {
Array.from(newTbody.children).forEach((row) => { Array.from(newTbody.children).forEach((row) => {
existingTbody.appendChild(document.adoptNode(row)) existingTbody.appendChild(document.adoptNode(row));
}) });
} }
// Check if there are more pages // Check if there are more pages
const newContainer = newTarget.querySelector("[data-infinite-scroll]") const newContainer = newTarget.querySelector(
const newNextUrl = newContainer && newContainer.getAttribute("data-next-url") "[data-infinite-scroll]",
);
const newNextUrl =
newContainer &&
newContainer.getAttribute("data-next-url");
if (newNextUrl) { if (newNextUrl) {
container.setAttribute("data-next-url", newNextUrl) container.setAttribute("data-next-url", newNextUrl);
const newSentinel = document.createElement("div") const newSentinel = document.createElement("div");
newSentinel.className = "infinite-scroll-sentinel h-4 md:hidden" newSentinel.className =
newSentinel.setAttribute("aria-hidden", "true") "infinite-scroll-sentinel h-4 md:hidden";
container.appendChild(newSentinel) newSentinel.setAttribute("aria-hidden", "true");
setupInfiniteScroll() container.appendChild(newSentinel);
setupInfiniteScroll();
} else { } else {
container.removeAttribute("data-next-url") container.removeAttribute("data-next-url");
container.removeAttribute("data-infinite-scroll") container.removeAttribute("data-infinite-scroll");
} }
}) });
}) });
}, },
{ rootMargin: "200px" } { rootMargin: "200px" },
) );
observer.observe(sentinel) observer.observe(sentinel);
}) });
} };
setupInfiniteScroll() setupInfiniteScroll();
const bodyObserver = new MutationObserver(() => { const bodyObserver = new MutationObserver(() => {
setupInfiniteScroll() setupInfiniteScroll();
}) });
bodyObserver.observe(document.body, { childList: true, subtree: true }) bodyObserver.observe(document.body, { childList: true, subtree: true });
}) });
</script> </script>
</head> </head>
<body class="min-h-screen bg-page text-text"> <body class="min-h-screen bg-page text-text">
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-4 md:py-10"> <main
class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-4 md:py-10"
>
{% include "partials/nav.html" %} {% block content %}{% endblock %} {% include "partials/nav.html" %} {% block content %}{% endblock %}
<footer <footer
class="mt-8 text-center text-xs tracking-widest text-text-muted small-caps" class="mt-8 text-center text-xs tracking-widest text-text-muted small-caps"

File diff suppressed because it is too large Load diff

View file

@ -6,27 +6,42 @@
{% block content %} {% block content %}
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Admin</h1> <h1 class="text-3xl font-semibold">Admin</h1>
<p class="max-w-2xl text-sm text-text-secondary">Manage passkeys, API tokens, and data.</p> <p class="max-w-2xl text-sm text-text-secondary">
Manage passkeys, API tokens, and data.
</p>
</header> </header>
<!-- Passkeys --> <!-- Passkeys -->
<section data-signals:_show-passkey-form="false" class="rounded-lg border bg-surface p-5"> <section
data-signals:_show-passkey-form="false"
class="rounded-lg border bg-surface p-5"
>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <div>
<h2 class="text-lg font-semibold text-text">Passkeys</h2> <h2 class="text-lg font-semibold text-text">Passkeys</h2>
<p class="mt-1 text-sm text-text-secondary">Passkeys enable secure sign-in without a password.</p> <p class="mt-1 text-sm text-text-secondary">
Passkeys enable secure sign-in without a password.
</p>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{% for passkey in passkeys %} {% for passkey in passkeys %}
<div class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3"> <div
class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3"
>
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
{{ icons::key("h-4 w-4 text-accent shrink-0") }} {{ icons::key("h-4 w-4 text-accent shrink-0") }}
<div class="min-w-0"> <div class="min-w-0">
<span class="block text-sm font-semibold text-text">{{ passkey.name }}</span> <span class="block text-sm font-semibold text-text"
>{{ passkey.name }}</span
>
<span class="block text-xs text-text-muted"> <span class="block text-xs text-text-muted">
Added {{ passkey.created_at }} Added {{ passkey.created_at }} ·
· {% if let Some(last_used) = passkey.last_used_at %}Last used {{ last_used }}{% else %}Never used{% endif %} {% if let Some(last_used) = passkey.last_used_at %}
Last used {{ last_used }}
{% else %}
Never used
{% endif %}
</span> </span>
</div> </div>
</div> </div>
@ -36,8 +51,7 @@
class="shrink-0 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt" class="shrink-0 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
onclick="deletePasskey({{ passkey.id }}, '{{ passkey.name }}')" onclick="deletePasskey({{ passkey.id }}, '{{ passkey.name }}')"
> >
{{ icons::delete("h-4 w-4") }} {{ icons::delete("h-4 w-4") }} Delete
Delete
</button> </button>
{% endif %} {% endif %}
</div> </div>
@ -45,13 +59,22 @@
</div> </div>
{% if passkeys.len() <= 1 %} {% if passkeys.len() <= 1 %}
<p class="text-xs text-text-muted">Add another passkey before removing the only one.</p> <p class="text-xs text-text-muted">
Add another passkey before removing the only one.
</p>
{% endif %} {% endif %}
<!-- Add passkey form --> <!-- Add passkey form -->
<div id="add-passkey-form" class="rounded-md border bg-surface-alt p-4" <div
data-show="$_showPasskeyForm" style="display: none"> id="add-passkey-form"
<div id="add-passkey-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"></div> class="rounded-md border bg-surface-alt p-4"
data-show="$_showPasskeyForm"
style="display: none"
>
<div
id="add-passkey-error"
class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"
></div>
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<label class="flex-1 flex flex-col gap-1 text-sm"> <label class="flex-1 flex flex-col gap-1 text-sm">
<span class="text-text">Passkey Name</span> <span class="text-text">Passkey Name</span>
@ -79,9 +102,12 @@
{{ icons::x_mark("h-4 w-4") }} Cancel {{ icons::x_mark("h-4 w-4") }} Cancel
</button> </button>
</div> </div>
<div id="add-passkey-loading" class="mt-3 hidden flex items-center gap-3 text-sm text-accent"> <div
{{ icons::spinner("h-5 w-5") }} id="add-passkey-loading"
Follow the prompts from the browser or device... class="mt-3 hidden flex items-center gap-3 text-sm text-accent"
>
{{ icons::spinner("h-5 w-5") }} Follow the prompts from the browser or
device...
</div> </div>
</div> </div>
@ -92,8 +118,7 @@
data-on:click="$_showPasskeyForm = true; setTimeout(() => document.getElementById('passkey-name').focus(), 50)" data-on:click="$_showPasskeyForm = true; setTimeout(() => document.getElementById('passkey-name').focus(), 50)"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
> >
{{ icons::key("h-4 w-4") }} {{ icons::key("h-4 w-4") }} New Passkey
New Passkey
</button> </button>
</div> </div>
</div> </div>
@ -111,7 +136,9 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <div>
<h2 class="text-lg font-semibold text-text">API Tokens</h2> <h2 class="text-lg font-semibold text-text">API Tokens</h2>
<p class="mt-1 text-sm text-text-secondary">Use tokens to authenticate the CLI or REST API.</p> <p class="mt-1 text-sm text-text-secondary">
Use tokens to authenticate the CLI or REST API.
</p>
</div> </div>
{% if tokens.is_empty() %} {% if tokens.is_empty() %}
@ -119,14 +146,23 @@
{% else %} {% else %}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{% for token in tokens %} {% for token in tokens %}
<div id="token-row-{{ token.id }}" class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3"> <div
id="token-row-{{ token.id }}"
class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3"
>
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
{{ icons::terminal("h-4 w-4 text-accent shrink-0") }} {{ icons::terminal("h-4 w-4 text-accent shrink-0") }}
<div class="min-w-0"> <div class="min-w-0">
<span class="block text-sm font-semibold text-text">{{ token.name }}</span> <span class="block text-sm font-semibold text-text"
>{{ token.name }}</span
>
<span class="block text-xs text-text-muted"> <span class="block text-xs text-text-muted">
Created {{ token.created_at }} Created {{ token.created_at }} ·
· {% if let Some(last_used) = token.last_used_at %}Last used {{ last_used }}{% else %}Never used{% endif %} {% if let Some(last_used) = token.last_used_at %}
Last used {{ last_used }}
{% else %}
Never used
{% endif %}
</span> </span>
</div> </div>
</div> </div>
@ -135,8 +171,7 @@
class="shrink-0 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt" class="shrink-0 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
onclick="revokeToken({{ token.id }}, '{{ token.name }}')" onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
> >
{{ icons::delete("h-4 w-4") }} {{ icons::delete("h-4 w-4") }} Revoke
Revoke
</button> </button>
</div> </div>
{% endfor %} {% endfor %}
@ -144,10 +179,17 @@
{% endif %} {% endif %}
<!-- Create token form --> <!-- Create token form -->
<div class="rounded-md border bg-surface-alt p-4" <div
data-show="$_showTokenForm && !$_tokenCreated" style="display: none"> class="rounded-md border bg-surface-alt p-4"
<p data-show="$_tokenError" data-text="$_tokenError" style="display: none" data-show="$_showTokenForm && !$_tokenCreated"
class="mb-3 rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"></p> style="display: none"
>
<p
data-show="$_tokenError"
data-text="$_tokenError"
style="display: none"
class="mb-3 rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"
></p>
<form <form
data-on:submit="$_creatingToken = true; $_tokenError = ''; @post('/api/v1/tokens', {contentType: 'form'})" data-on:submit="$_creatingToken = true; $_tokenError = ''; @post('/api/v1/tokens', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_creatingToken) return; data-on:datastar-fetch="if (!$_creatingToken) return;
@ -185,8 +227,11 @@
</div> </div>
<!-- One-time token display --> <!-- One-time token display -->
<div data-show="$_tokenCreated" style="display: none" <div
class="relative rounded-md border border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-950/40 p-4"> data-show="$_tokenCreated"
style="display: none"
class="relative rounded-md border border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-950/40 p-4"
>
<button <button
type="button" type="button"
onclick="window.location.reload()" onclick="window.location.reload()"
@ -195,10 +240,15 @@
> >
{{ icons::x_mark("h-4 w-4") }} {{ icons::x_mark("h-4 w-4") }}
</button> </button>
<p class="pr-6 text-sm font-medium text-green-800 dark:text-green-300">Token created! Copy it now — it will not be shown again.</p> <p class="pr-6 text-sm font-medium text-green-800 dark:text-green-300">
Token created! Copy it now — it will not be shown again.
</p>
<div class="mt-3 flex items-center gap-2"> <div class="mt-3 flex items-center gap-2">
<code id="token-value" data-text="$_tokenValue" <code
class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-text border border-green-200 dark:border-green-800 break-all select-all"></code> id="token-value"
data-text="$_tokenValue"
class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-text border border-green-200 dark:border-green-800 break-all select-all"
></code>
<button <button
type="button" type="button"
onclick="copyToken(this)" onclick="copyToken(this)"
@ -216,8 +266,7 @@
data-on:click="$_showTokenForm = true; setTimeout(() => document.getElementById('token-name').focus(), 50)" data-on:click="$_showTokenForm = true; setTimeout(() => document.getElementById('token-name').focus(), 50)"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
> >
{{ icons::terminal("h-4 w-4") }} {{ icons::terminal("h-4 w-4") }} New Token
New Token
</button> </button>
</div> </div>
</div> </div>
@ -228,7 +277,10 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <div>
<h2 class="text-lg font-semibold text-text">Data</h2> <h2 class="text-lg font-semibold text-text">Data</h2>
<p class="mt-1 text-sm text-text-secondary">Export all coffee data as JSON, restore from a previous backup, or reset to start fresh.</p> <p class="mt-1 text-sm text-text-secondary">
Export all coffee data as JSON, restore from a previous backup, or
reset to start fresh.
</p>
</div> </div>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap"> <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
@ -237,16 +289,14 @@
download download
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
> >
{{ icons::arrow_down_tray("h-4 w-4") }} {{ icons::arrow_down_tray("h-4 w-4") }} New Backup
New Backup
</a> </a>
<button <button
type="button" type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44" class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
onclick="document.getElementById('restore-file-input').click()" onclick="document.getElementById('restore-file-input').click()"
> >
{{ icons::arrow_up_tray("h-4 w-4") }} {{ icons::arrow_up_tray("h-4 w-4") }} Restore
Restore
</button> </button>
<button <button
type="button" type="button"
@ -258,8 +308,12 @@
data-attr:disabled="$_recomputing" data-attr:disabled="$_recomputing"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44" class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44"
> >
<span data-show="!$_recomputing">{{ icons::refresh("h-4 w-4") }}</span> <span data-show="!$_recomputing"
<span data-show="$_recomputing" style="display:none">{{ icons::spinner("h-4 w-4") }}</span> >{{ icons::refresh("h-4 w-4") }}</span
>
<span data-show="$_recomputing" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span
>
Recompute Stats Recompute Stats
</button> </button>
<button <button
@ -267,16 +321,26 @@
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44" class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
onclick="resetDatabase()" onclick="resetDatabase()"
> >
{{ icons::delete("h-4 w-4") }} {{ icons::delete("h-4 w-4") }} Reset
Reset
</button> </button>
</div> </div>
<input type="file" id="restore-file-input" accept=".json" class="hidden" <input
onchange="restoreFromFile(this)" /> type="file"
id="restore-file-input"
accept=".json"
class="hidden"
onchange="restoreFromFile(this)"
/>
<div id="backup-status" class="hidden rounded-md border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:bg-green-950/40 dark:border-green-800 dark:text-green-300"></div> <div
<div id="backup-error" class="hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"></div> id="backup-status"
class="hidden rounded-md border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:bg-green-950/40 dark:border-green-800 dark:text-green-300"
></div>
<div
id="backup-error"
class="hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"
></div>
</div> </div>
</section> </section>
@ -289,15 +353,21 @@
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div> <div>
<span class="block text-sm text-text-muted">Total Cost</span> <span class="block text-sm text-text-muted">Total Cost</span>
<span class="mt-1 block text-lg font-semibold text-text">{{ usage.cost }}</span> <span class="mt-1 block text-lg font-semibold text-text"
>{{ usage.cost }}</span
>
</div> </div>
<div> <div>
<span class="block text-sm text-text-muted">API Calls</span> <span class="block text-sm text-text-muted">API Calls</span>
<span class="mt-1 block text-lg font-semibold text-text">{{ usage.calls }}</span> <span class="mt-1 block text-lg font-semibold text-text"
>{{ usage.calls }}</span
>
</div> </div>
<div> <div>
<span class="block text-sm text-text-muted">Total Tokens</span> <span class="block text-sm text-text-muted">Total Tokens</span>
<span class="mt-1 block text-lg font-semibold text-text">{{ usage.tokens }}</span> <span class="mt-1 block text-lg font-semibold text-text"
>{{ usage.tokens }}</span
>
</div> </div>
</div> </div>
</div> </div>
@ -313,9 +383,11 @@
</div> </div>
<div> <div>
<form method="post" action="/logout"> <form method="post" action="/logout">
<button type="submit" class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"> <button
{{ icons::logout("h-4 w-4") }} type="submit"
Sign Out class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
>
{{ icons::logout("h-4 w-4") }} Sign Out
</button> </button>
</form> </form>
</div> </div>
@ -359,7 +431,9 @@
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(token).then(() => { navigator.clipboard.writeText(token).then(() => {
btn.textContent = "Copied!"; btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 2000); setTimeout(() => {
btn.textContent = "Copy";
}, 2000);
}); });
} else { } else {
const range = document.createRange(); const range = document.createRange();
@ -377,7 +451,11 @@
if (!file) return; if (!file) return;
input.value = ""; input.value = "";
if (!confirm("Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.")) { if (
!confirm(
"Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.",
)
) {
return; return;
} }
@ -397,7 +475,9 @@
}); });
if (response.status === 409) { if (response.status === 409) {
throw new Error("Database is not empty. Restore requires an empty database."); throw new Error(
"Database is not empty. Restore requires an empty database.",
);
} }
if (!response.ok) { if (!response.ok) {
throw new Error(`Restore failed (HTTP ${response.status}).`); throw new Error(`Restore failed (HTTP ${response.status}).`);
@ -406,13 +486,18 @@
status.textContent = "Backup restored successfully."; status.textContent = "Backup restored successfully.";
status.classList.remove("hidden"); status.classList.remove("hidden");
} catch (err) { } catch (err) {
error.textContent = err instanceof SyntaxError ? "Invalid JSON file." : err.message; error.textContent =
err instanceof SyntaxError ? "Invalid JSON file." : err.message;
error.classList.remove("hidden"); error.classList.remove("hidden");
} }
}; };
const resetDatabase = async () => { 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.")) { 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; return;
} }
@ -426,13 +511,16 @@
error.classList.add("hidden"); error.classList.add("hidden");
try { try {
const response = await fetch("/api/v1/backup/reset", { method: "POST" }); const response = await fetch("/api/v1/backup/reset", {
method: "POST",
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Reset failed (HTTP ${response.status}).`); throw new Error(`Reset failed (HTTP ${response.status}).`);
} }
status.textContent = "Database reset successfully. All coffee data has been deleted."; status.textContent =
"Database reset successfully. All coffee data has been deleted.";
status.classList.remove("hidden"); status.classList.remove("hidden");
} catch (err) { } catch (err) {
error.textContent = err.message; error.textContent = err.message;
@ -444,7 +532,9 @@
if (!confirm(`Delete passkey "${name}"? This cannot be undone.`)) return; if (!confirm(`Delete passkey "${name}"? This cannot be undone.`)) return;
try { try {
const response = await fetch(`/api/v1/passkeys/${id}`, { method: "DELETE" }); const response = await fetch(`/api/v1/passkeys/${id}`, {
method: "DELETE",
});
if (response.ok) { if (response.ok) {
window.location.reload(); window.location.reload();
} else if (response.status === 409) { } else if (response.status === 409) {
@ -461,7 +551,9 @@
if (!confirm(`Revoke token "${name}"? This cannot be undone.`)) return; if (!confirm(`Revoke token "${name}"? This cannot be undone.`)) return;
try { try {
const response = await fetch(`/api/v1/tokens/${id}/revoke`, { method: "POST" }); const response = await fetch(`/api/v1/tokens/${id}/revoke`, {
method: "POST",
});
if (response.ok) { if (response.ok) {
const row = document.getElementById(`token-row-${id}`); const row = document.getElementById(`token-row-${id}`);
if (row) row.remove(); if (row) row.remove();

View file

@ -2,16 +2,25 @@
{% import "partials/detail_cards.html" as detail %} {% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %} {% block title %}Brewlog · {{ bag.roast_name }}{% endblock %}
{% block description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %} {% block description %}
{{ bag.roast_name }}
by {{ bag.roaster_name }} — {{ bag.amount }} bag.
{% endblock %}
{% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %} {% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %}
{% block og_description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %} {% block og_description %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %} {{ bag.roast_name }}
by {{ bag.roaster_name }} — {{ bag.amount }} bag.
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% block content %} {% block content %}
<header class="flex items-start justify-between gap-4"> <header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ bag.roast_name }}</h1> <h1 class="text-3xl font-semibold">{{ bag.roast_name }}</h1>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{{ bag.roaster_name }} · {{ bag.amount }} · Opened {{ bag.created_date }} {{ bag.roaster_name }} · {{ bag.amount }} · Opened
{{ bag.created_date }}
</p> </p>
</div> </div>
</header> </header>
@ -41,9 +50,14 @@
{% else %} {% else %}
<div class="mt-1"> <div class="mt-1">
<div class="h-2 rounded-full bg-surface-alt overflow-hidden"> <div class="h-2 rounded-full bg-surface-alt overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div> <div
class="h-full rounded-full bg-accent"
style="width: {{ bag.used_percent }}%"
></div>
</div> </div>
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">{{ bag.remaining }} / {{ bag.amount }}</p> <p class="mt-1 text-text-muted" style="font-size: 0.7rem">
{{ bag.remaining }} / {{ bag.amount }}
</p>
</div> </div>
{% endif %} {% endif %}
</dd> </dd>
@ -71,17 +85,23 @@
{% if is_authenticated %} {% if is_authenticated %}
{# ── Actions ── #} {# ── Actions ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5 md:col-span-2 flex items-center gap-2"> <div
class="rounded-lg border bg-surface p-5 md:col-span-2 flex items-center gap-2"
>
{% if !bag.closed %} {% if !bag.closed %}
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt" class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&remaining=0')"> data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&remaining=0')"
>
{{ icons::x_mark("h-4 w-4") }} Close Bag {{ icons::x_mark("h-4 w-4") }} Close Bag
</button> </button>
{% endif %} {% endif %}
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt" class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this bag? This cannot be undone.') && @delete('/api/v1/bags/{{ bag.id }}')"> data-on:click="confirm('Delete this bag? This cannot be undone.') && @delete('/api/v1/bags/{{ bag.id }}')"
>
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>

View file

@ -2,16 +2,27 @@
{% import "partials/detail_cards.html" as detail %} {% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %} {% 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 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_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 og_description %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %} {{ brew.roast_name }}
by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee,
{{ brew.water_volume }} water.
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% block content %} {% block content %}
<header class="flex items-start justify-between gap-4"> <header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ brew.roast_name }}</h1> <h1 class="text-3xl font-semibold">{{ brew.roast_name }}</h1>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }} {{ brew.roaster_name }} · Brewed {{ brew.created_date }} at
{{ brew.created_time }}
</p> </p>
</div> </div>
</header> </header>
@ -86,9 +97,11 @@
{% if is_authenticated %} {% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt" class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this brew? This cannot be undone.') && @delete('/api/v1/brews/{{ brew.id }}')"> data-on:click="confirm('Delete this brew? This cannot be undone.') && @delete('/api/v1/brews/{{ brew.id }}')"
>
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>

View file

@ -3,14 +3,20 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ cafe.name }}{% endblock %} {% block title %}Brewlog · {{ cafe.name }}{% endblock %}
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %} {% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
{% block og_description %}{{ cafe.name }} — {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }}{% endblock %} {% block og_description %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %} {{ cafe.name }}
— {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }}
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% block content %} {% block content %}
<header class="flex items-start justify-between gap-4"> <header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ cafe.name }}</h1> <h1 class="text-3xl font-semibold">{{ cafe.name }}</h1>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added {{ cafe.created_date }} {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added
{{ cafe.created_date }}
</p> </p>
</div> </div>
</header> </header>
@ -33,7 +39,13 @@
<div> <div>
<dt class="text-text-muted">Website</dt> <dt class="text-text-muted">Website</dt>
<dd class="font-medium"> <dd class="font-medium">
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a> <a
href="{{ url }}"
target="_blank"
rel="noreferrer noopener"
class="text-accent hover:text-accent-hover transition"
>Visit Website</a
>
</dd> </dd>
</div> </div>
{% endif %} {% endif %}
@ -45,9 +57,11 @@
{% if is_authenticated %} {% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt" class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this cafe? This cannot be undone.') && @delete('/api/v1/cafes/{{ cafe.id }}')"> data-on:click="confirm('Delete this cafe? This cannot be undone.') && @delete('/api/v1/cafes/{{ cafe.id }}')"
>
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>

View file

@ -1,10 +1,22 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import {% extends "base.html" %} {% import "partials/icons.html" as icons %}
"partials/scan_input.html" as scan %} {% import "partials/location_search.html" as location %} {% {%
block title %}Brewlog · Check In{% endblock %} {% block head %} import
"partials/scan_input.html" as scan
%}
{% import "partials/location_search.html" as location %}
{% block title %}
Brewlog · Check In
{% endblock %}
{% block head %}
<script> <script>
{{ location::location_search_js() }} {
{
location::location_search_js();
}
}
</script> </script>
{% endblock %} {% block content %} {% endblock %}
{% block content %}
<section <section
id="checkin-root" id="checkin-root"
data-signals:_step="1" data-signals:_step="1"
@ -33,13 +45,18 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
> >
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Check In</h1> <h1 class="text-3xl font-semibold">Check In</h1>
<p class="max-w-2xl text-sm text-text-secondary">Record a cup of coffee at a cafe.</p> <p class="max-w-2xl text-sm text-text-secondary">
Record a cup of coffee at a cafe.
</p>
</header> </header>
<!-- Step progress --> <!-- Step progress -->
<div class="relative mx-auto mt-6" style="width: 80%"> <div class="relative mx-auto mt-6" style="width: 80%">
<!-- Background track (continuous line through dot centers) --> <!-- Background track (continuous line through dot centers) -->
<div class="absolute bg-border" style="height: 3px; top: 8px; left: 10px; right: 10px"></div> <div
class="absolute bg-border"
style="height: 3px; top: 8px; left: 10px; right: 10px"
></div>
<!-- Accent overlay: first segment --> <!-- Accent overlay: first segment -->
<div <div
class="absolute bg-accent" class="absolute bg-accent"
@ -153,16 +170,27 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
<!-- Step 1: Cafe selection --> <!-- Step 1: Cafe selection -->
<div class="mt-4" data-show="$_step === 1" style="display: none"> <div class="mt-4" data-show="$_step === 1" style="display: none">
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<div data-show="!$_reviewingCafe">{{ location::location_search("$_error") }}</div> <div data-show="!$_reviewingCafe">
{{ location::location_search("$_error") }}
</div>
<!-- Review form for new cafe from Foursquare --> <!-- Review form for new cafe from Foursquare -->
<div data-show="$_reviewingCafe" style="display: none"> <div data-show="$_reviewingCafe" style="display: none">
<h3 class="text-base font-semibold text-text mb-1">Confirm cafe details</h3> <h3 class="text-base font-semibold text-text mb-1">
<p class="text-sm text-text-secondary mb-3">Review and edit the details before saving.</p> Confirm cafe details
</h3>
<p class="text-sm text-text-secondary mb-3">
Review and edit the details before saving.
</p>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Name *</span> <span class="text-text">Name *</span>
<input type="text" data-bind:_cafe-name required class="input-field" /> <input
type="text"
data-bind:_cafe-name
required
class="input-field"
/>
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">City</span> <span class="text-text">City</span>
@ -203,7 +231,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
<!-- Saved cafes --> <!-- Saved cafes -->
{% if !cafe_options.is_empty() %} {% if !cafe_options.is_empty() %}
<div class="mt-3 border-t pt-3" data-show="!$_reviewingCafe"> <div class="mt-3 border-t pt-3" data-show="!$_reviewingCafe">
<p class="mb-2 text-sm text-text-secondary">Or choose a saved cafe:</p> <p class="mb-2 text-sm text-text-secondary">
Or choose a saved cafe:
</p>
<searchable-select <searchable-select
name="saved_cafe_id" name="saved_cafe_id"
placeholder="Type to search saved cafes&hellip;" placeholder="Type to search saved cafes&hellip;"
@ -218,7 +248,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition" class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
> >
<span class="font-medium text-text">{{ cafe.name }}</span> <span class="font-medium text-text">{{ cafe.name }}</span>
<span class="ml-2 text-xs text-text-muted">{{ cafe.city }}</span> <span class="ml-2 text-xs text-text-muted"
>{{ cafe.city }}</span
>
</button> </button>
{% endfor %} {% endfor %}
</searchable-select> </searchable-select>
@ -234,7 +266,8 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
<div data-show="!$_scanSuccess" class="mb-4"> <div data-show="!$_scanSuccess" class="mb-4">
<p class="text-sm text-text-secondary mb-3" data-show="!$_extracting"> <p class="text-sm text-text-secondary mb-3" data-show="!$_extracting">
Scan a bag to identify the coffee, or select from existing roasts below. Scan a bag to identify the coffee, or select from existing roasts
below.
</p> </p>
<form <form
@ -242,8 +275,10 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/scan', {contentType: 'form'})" data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Scan failed. Please try again.' }" data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Scan failed. Please try again.' }"
> >
{{ scan::scan_input("checkin-scan-form", "checkin-image", "$_extracting", {{
"$_extractError", "Describe a coffee bag…", "Scanning…") }} scan::scan_input("checkin-scan-form", "checkin-image", "$_extracting",
"$_extractError", "Describe a coffee bag…", "Scanning…")
}}
</form> </form>
</div> </div>
@ -257,7 +292,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
{% if !roast_options.is_empty() %} {% if !roast_options.is_empty() %}
<div class="border-t pt-3"> <div class="border-t pt-3">
<p class="text-sm text-text-secondary mb-2">Or select an existing roast:</p> <p class="text-sm text-text-secondary mb-2">
Or select an existing roast:
</p>
<searchable-select <searchable-select
name="roast_id" name="roast_id"
placeholder="Type to search existing roasts&hellip;" placeholder="Type to search existing roasts&hellip;"
@ -272,7 +309,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition" class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
> >
<span class="font-medium text-text">{{ roast.name }}</span> <span class="font-medium text-text">{{ roast.name }}</span>
<span class="ml-2 text-xs text-text-muted">{{ roast.roaster_name }}</span> <span class="ml-2 text-xs text-text-muted"
>{{ roast.roaster_name }}</span
>
</button> </button>
{% endfor %} {% endfor %}
</searchable-select> </searchable-select>
@ -284,7 +323,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
<!-- Step 3: Review + submit --> <!-- Step 3: Review + submit -->
<div class="mt-4" data-show="$_step === 3" style="display: none"> <div class="mt-4" data-show="$_step === 3" style="display: none">
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h3 class="text-base font-semibold text-text mb-4">Review &amp; Submit</h3> <h3 class="text-base font-semibold text-text mb-4">
Review &amp; Submit
</h3>
<dl class="flex flex-col gap-3 text-sm mb-6"> <dl class="flex flex-col gap-3 text-sm mb-6">
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<dt class="font-medium text-text-muted">Cafe</dt> <dt class="font-medium text-text-muted">Cafe</dt>
@ -309,10 +350,18 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
<input type="hidden" name="cafe_id" data-attr:value="$_cafeId" /> <input type="hidden" name="cafe_id" data-attr:value="$_cafeId" />
<input type="hidden" name="cafe_name" data-attr:value="$_cafeName" /> <input type="hidden" name="cafe_name" data-attr:value="$_cafeName" />
<input type="hidden" name="cafe_city" data-attr:value="$_cafeCity" /> <input type="hidden" name="cafe_city" data-attr:value="$_cafeCity" />
<input type="hidden" name="cafe_country" data-attr:value="$_cafeCountry" /> <input
type="hidden"
name="cafe_country"
data-attr:value="$_cafeCountry"
/>
<input type="hidden" name="cafe_lat" data-attr:value="$_cafeLat" /> <input type="hidden" name="cafe_lat" data-attr:value="$_cafeLat" />
<input type="hidden" name="cafe_lng" data-attr:value="$_cafeLng" /> <input type="hidden" name="cafe_lng" data-attr:value="$_cafeLng" />
<input type="hidden" name="cafe_website" data-attr:value="$_cafeWebsite" /> <input
type="hidden"
name="cafe_website"
data-attr:value="$_cafeWebsite"
/>
<input type="hidden" name="roast_id" data-attr:value="$_roastId" /> <input type="hidden" name="roast_id" data-attr:value="$_roastId" />
<button <button
type="submit" type="submit"

View file

@ -8,14 +8,19 @@
{{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }} {{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }}
<h1 class="text-2xl font-semibold text-accent">CLI Authenticated</h1> <h1 class="text-2xl font-semibold text-accent">CLI Authenticated</h1>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
Your CLI has been authenticated successfully. You can close this window and return to the terminal. Your CLI has been authenticated successfully. You can close this
window and return to the terminal.
</p> </p>
</div> </div>
{% else if error.is_some() %} {% else if error.is_some() %}
<div class="flex flex-col items-center text-center gap-3"> <div class="flex flex-col items-center text-center gap-3">
{{ icons::x_circle("h-10 w-10 text-red-600 dark:text-red-400") }} {{ icons::x_circle("h-10 w-10 text-red-600 dark:text-red-400") }}
<h1 class="text-2xl font-semibold text-accent">Authentication Failed</h1> <h1 class="text-2xl font-semibold text-accent">
<div class="w-full rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"> Authentication Failed
</h1>
<div
class="w-full rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"
>
{{ error.as_ref().unwrap() }} {{ error.as_ref().unwrap() }}
</div> </div>
</div> </div>

View file

@ -2,16 +2,25 @@
{% import "partials/detail_cards.html" as detail %} {% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %} {% 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 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_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 og_description %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %} {{ cup.roast_name }}
by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% block content %} {% block content %}
<header class="flex items-start justify-between gap-4"> <header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ cup.roast_name }}</h1> <h1 class="text-3xl font-semibold">{{ cup.roast_name }}</h1>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }} {{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} ·
{{ cup.created_date }}
</p> </p>
</div> </div>
</header> </header>
@ -47,7 +56,13 @@
<div> <div>
<dt class="text-text-muted">Website</dt> <dt class="text-text-muted">Website</dt>
<dd class="font-medium"> <dd class="font-medium">
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a> <a
href="{{ url }}"
target="_blank"
rel="noreferrer noopener"
class="text-accent hover:text-accent-hover transition"
>Visit Website</a
>
</dd> </dd>
</div> </div>
{% endif %} {% endif %}
@ -57,9 +72,11 @@
{% if is_authenticated %} {% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt" class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this cup? This cannot be undone.') && @delete('/api/v1/cups/{{ cup.id }}')"> data-on:click="confirm('Delete this cup? This cannot be undone.') && @delete('/api/v1/cups/{{ cup.id }}')"
>
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>

View file

@ -1,4 +1,5 @@
{% 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 %} {% block content %}
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Data</h1> <h1 class="text-3xl font-semibold">Data</h1>
@ -19,9 +20,7 @@
/> />
</div> </div>
<div id="data-content" class="data-page-content"> <div id="data-content" class="data-page-content">{{ content|safe }}</div>
{{ content|safe }}
</div>
</div> </div>
<div id="detail-panel"></div> <div id="detail-panel"></div>

View file

@ -2,8 +2,14 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %} {% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %} {% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
{% block og_description %}{{ gear.make }} {{ gear.model }} — {{ gear.category_label }}{% endblock %} {% block og_description %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %} {{ gear.make }}
{{ gear.model }}
— {{ gear.category_label }}
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% block content %} {% block content %}
<header class="flex items-start justify-between gap-4"> <header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@ -34,9 +40,11 @@
{% if is_authenticated %} {% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt" class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this gear? This cannot be undone.') && @delete('/api/v1/gear/{{ gear.id }}')"> data-on:click="confirm('Delete this gear? This cannot be undone.') && @delete('/api/v1/gear/{{ gear.id }}')"
>
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>

View file

@ -1,7 +1,14 @@
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import {% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %}
"partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %} {%
import
"partials/brew_card.html" as brew_card
%}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" /> <meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %} {% block content %} {% endblock %}
{% block content %}
<!-- Scan Bag --> <!-- Scan Bag -->
{% if is_authenticated %} {% if is_authenticated %}
<section <section
@ -28,19 +35,29 @@
<!-- Quick actions (shown when not yet extracted) --> <!-- Quick actions (shown when not yet extracted) -->
<div data-show="!$_scanExtracted"> <div data-show="!$_scanExtracted">
<div data-show="!$_extracting" class="grid grid-cols-3 gap-4"> <div data-show="!$_extracting" class="grid grid-cols-3 gap-4">
<input type="hidden" name="image" id="scan-image" form="scan-extract-form" /> <input
<form id="scan-extract-form" type="hidden"
name="image"
id="scan-image"
form="scan-extract-form"
/>
<form
id="scan-extract-form"
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})" data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }" data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
class="hidden"></form> class="hidden"
<brew-photo-capture target-input="scan-image" target-form="scan-extract-form" ></form>
<brew-photo-capture
target-input="scan-image"
target-form="scan-extract-form"
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40 cursor-pointer" class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40 cursor-pointer"
aria-label="Scan Bag" aria-label="Scan Bag"
> >
{{ icons::camera("h-5 w-5") }} {{ icons::camera("h-5 w-5") }}
<span class="text-sm font-medium">Scan</span> <span class="text-sm font-medium">Scan</span>
</brew-photo-capture> </brew-photo-capture>
<a href="/check-in" <a
href="/check-in"
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40" class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40"
title="Check in at a cafe" title="Check in at a cafe"
aria-label="Check in at a cafe" aria-label="Check in at a cafe"
@ -48,7 +65,8 @@
{{ icons::location("h-5 w-5") }} {{ icons::location("h-5 w-5") }}
<span class="text-sm font-medium">Check In</span> <span class="text-sm font-medium">Check In</span>
</a> </a>
<a href="/add" <a
href="/add"
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40" class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40"
title="Add manually" title="Add manually"
aria-label="Add manually" aria-label="Add manually"
@ -57,11 +75,19 @@
<span class="text-sm font-medium">Add</span> <span class="text-sm font-medium">Add</span>
</a> </a>
</div> </div>
<div data-show="$_extracting" style="display:none" class="flex items-center justify-center gap-3 text-sm text-accent"> <div
{{ icons::spinner("h-5 w-5") }} data-show="$_extracting"
Waiting for response&hellip; style="display:none"
class="flex items-center justify-center gap-3 text-sm text-accent"
>
{{ icons::spinner("h-5 w-5") }} Waiting for response&hellip;
</div> </div>
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600 text-center"></p> <p
data-show="$_extractError"
data-text="$_extractError"
style="display:none"
class="mt-2 text-sm text-red-600 text-center"
></p>
</div> </div>
<!-- Form: pre-filled roaster + roast (shown after extraction) --> <!-- Form: pre-filled roaster + roast (shown after extraction) -->
@ -81,33 +107,49 @@
<section data-signals:_brewing="false"> <section data-signals:_brewing="false">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-text">Recent Brews</h2> <h2 class="text-lg font-semibold text-text">Recent Brews</h2>
<a href="/data?type=brews" class="text-sm text-accent hover:text-accent-hover font-medium" <a
href="/data?type=brews"
class="text-sm text-accent hover:text-accent-hover font-medium"
>View all brews &rarr;</a >View all brews &rarr;</a
> >
</div> </div>
{% if !recent_brews.is_empty() %} {% if !recent_brews.is_empty() %}
<chip-scroll class="relative block"> <chip-scroll class="relative block">
<button type="button" aria-label="Scroll left" <button
type="button"
aria-label="Scroll left"
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-left data-scroll-left
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})"
>
{{ icons::chevron_left("h-5 w-5") }} {{ icons::chevron_left("h-5 w-5") }}
</button> </button>
<div class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide" data-chip-scroll> <div
class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide"
data-chip-scroll
>
{% for brew in recent_brews %}{{ brew_card::card(brew, is_authenticated) }}{% endfor %} {% for brew in recent_brews %}{{ brew_card::card(brew, is_authenticated) }}{% endfor %}
</div> </div>
<button type="button" aria-label="Scroll right" <button
type="button"
aria-label="Scroll right"
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-right data-scroll-right
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</button> </button>
</chip-scroll> </chip-scroll>
{% else %} {% else %}
<div class="relative"> <div class="relative">
<div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div
class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none"
aria-hidden="true"
>
{% for _ in 0..6 %} {% for _ in 0..6 %}
<div class="w-[45vw] md:w-[200px] h-[279px] shrink-0 rounded-lg border bg-surface p-4 flex flex-col"> <div
class="w-[45vw] md:w-[200px] h-[279px] shrink-0 rounded-lg border bg-surface p-4 flex flex-col"
>
<div class="h-[4.5rem]"> <div class="h-[4.5rem]">
<div class="h-4 w-3/4 rounded bg-surface-alt"></div> <div class="h-4 w-3/4 rounded bg-surface-alt"></div>
<div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div> <div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div>
@ -123,7 +165,9 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="absolute inset-0 z-10 flex items-center justify-center"> <div class="absolute inset-0 z-10 flex items-center justify-center">
<span class="text-lg font-semibold text-text-muted">No brews yet</span> <span class="text-lg font-semibold text-text-muted"
>No brews yet</span
>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -133,16 +177,21 @@
<section id="open-bags-section"> <section id="open-bags-section">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-text">Open Bags</h2> <h2 class="text-lg font-semibold text-text">Open Bags</h2>
<a href="/data?type=bags" class="text-sm text-accent hover:text-accent-hover font-medium" <a
href="/data?type=bags"
class="text-sm text-accent hover:text-accent-hover font-medium"
>View all bags &rarr;</a >View all bags &rarr;</a
> >
</div> </div>
{% if !open_bags.is_empty() %} {% if !open_bags.is_empty() %}
<chip-scroll class="relative block"> <chip-scroll class="relative block">
<button type="button" aria-label="Scroll left" <button
type="button"
aria-label="Scroll left"
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-left data-scroll-left
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})"
>
{{ icons::chevron_left("h-5 w-5") }} {{ icons::chevron_left("h-5 w-5") }}
</button> </button>
<div <div
@ -152,18 +201,26 @@
> >
{% for bag in open_bags %}{{ bag_card::card(bag, is_authenticated) }}{% endfor %} {% for bag in open_bags %}{{ bag_card::card(bag, is_authenticated) }}{% endfor %}
</div> </div>
<button type="button" aria-label="Scroll right" <button
type="button"
aria-label="Scroll right"
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-right data-scroll-right
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</button> </button>
</chip-scroll> </chip-scroll>
{% else %} {% else %}
<div class="relative"> <div class="relative">
<div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div
class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none"
aria-hidden="true"
>
{% for _ in 0..6 %} {% for _ in 0..6 %}
<div class="w-[45vw] md:w-[200px] h-[200px] shrink-0 rounded-lg border bg-surface p-4 flex flex-col"> <div
class="w-[45vw] md:w-[200px] h-[200px] shrink-0 rounded-lg border bg-surface p-4 flex flex-col"
>
<div class="flex-1"> <div class="flex-1">
<div class="h-4 w-3/4 rounded bg-surface-alt"></div> <div class="h-4 w-3/4 rounded bg-surface-alt"></div>
<div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div> <div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div>
@ -180,7 +237,9 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="absolute inset-0 z-10 flex items-center justify-center"> <div class="absolute inset-0 z-10 flex items-center justify-center">
<span class="text-lg font-semibold text-text-muted">No open bags</span> <span class="text-lg font-semibold text-text-muted"
>No open bags</span
>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -190,21 +249,28 @@
<section> <section>
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-text">Recent Activity</h2> <h2 class="text-lg font-semibold text-text">Recent Activity</h2>
<a href="/timeline" class="text-sm text-accent hover:text-accent-hover font-medium" <a
href="/timeline"
class="text-sm text-accent hover:text-accent-hover font-medium"
>View full timeline &rarr;</a >View full timeline &rarr;</a
> >
</div> </div>
{% if !recent_events.is_empty() %} {% if !recent_events.is_empty() %}
<div class="space-y-2"> <div class="space-y-2">
{% for event in recent_events %} {% for event in recent_events %}
<a href="{{ event.link }}" class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3 transition hover:border-accent/40"> <a
href="{{ event.link }}"
class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3 transition hover:border-accent/40"
>
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<span class="text-text-muted shrink-0"> <span class="text-text-muted shrink-0">
{% if event.entity_type == "brew" %}{{ icons::beaker("h-5 w-5") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-5 w-5") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-5 w-5") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-5 w-5") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-5 w-5") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-5 w-5") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-5 w-5") }}{% else %}{{ icons::beaker("h-5 w-5") }}{% endif %} {% if event.entity_type == "brew" %}{{ icons::beaker("h-5 w-5") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-5 w-5") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-5 w-5") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-5 w-5") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-5 w-5") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-5 w-5") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-5 w-5") }}{% else %}{{ icons::beaker("h-5 w-5") }}{% endif %}
</span> </span>
<p class="min-w-0 truncate text-sm"> <p class="min-w-0 truncate text-sm">
<span class="font-medium text-text">{{ event.title }}</span>{% if let Some(sub) = event.subtitle %} <span class="font-medium text-text">{{ event.title }}</span
<span class="text-xs text-text-muted">· {{ sub }}</span>{% endif %} >{% if let Some(sub) = event.subtitle %}
<span class="text-xs text-text-muted">· {{ sub }}</span>
{% endif %}
</p> </p>
</div> </div>
<time class="text-xs text-text-muted whitespace-nowrap shrink-0" <time class="text-xs text-text-muted whitespace-nowrap shrink-0"
@ -215,9 +281,14 @@
</div> </div>
{% else %} {% else %}
<div class="relative"> <div class="relative">
<div class="space-y-2 blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div
class="space-y-2 blur-[2px] select-none pointer-events-none"
aria-hidden="true"
>
{% for _ in 0..3 %} {% for _ in 0..3 %}
<div class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3"> <div
class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3"
>
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<div class="h-5 w-5 shrink-0 rounded-full bg-surface-alt"></div> <div class="h-5 w-5 shrink-0 rounded-full bg-surface-alt"></div>
<div class="h-4 w-40 rounded bg-surface-alt"></div> <div class="h-4 w-40 rounded bg-surface-alt"></div>
@ -227,7 +298,9 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="absolute inset-0 z-10 flex items-center justify-center"> <div class="absolute inset-0 z-10 flex items-center justify-center">
<span class="text-lg font-semibold text-text-muted">No activity yet</span> <span class="text-lg font-semibold text-text-muted"
>No activity yet</span
>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -237,39 +310,62 @@
<section> <section>
<div class="flex items-center justify-between mb-5"> <div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Stats</h2> <h2 class="text-lg font-semibold text-text">Stats</h2>
<a href="/stats" class="text-sm text-accent hover:text-accent-hover font-medium" <a
href="/stats"
class="text-sm text-accent hover:text-accent-hover font-medium"
>View all stats &rarr;</a >View all stats &rarr;</a
> >
</div> </div>
{% if !stats.is_empty() && !stat_cards.is_empty() %} {% if !stats.is_empty() && !stat_cards.is_empty() %}
<chip-scroll class="relative block"> <chip-scroll class="relative block">
<button type="button" aria-label="Scroll left" <button
type="button"
aria-label="Scroll left"
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-left data-scroll-left
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})"
>
{{ icons::chevron_left("h-5 w-5") }} {{ icons::chevron_left("h-5 w-5") }}
</button> </button>
<div class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll> <div
class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-x-auto scroll-smooth scrollbar-hide"
data-chip-scroll
>
{% for card in stat_cards %} {% for card in stat_cards %}
<a href="/stats" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"> <a
href="/stats"
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
>
{% if card.icon == "coffee_bean" %}{{ icons::coffee_bean("h-6 w-6 text-accent") }}{% else if card.icon == "beaker" %}{{ icons::beaker("h-6 w-6 text-accent") }}{% else if card.icon == "map" %}{{ icons::map("h-6 w-6 text-accent") }}{% else if card.icon == "location" %}{{ icons::location("h-6 w-6 text-accent") }}{% else if card.icon == "fire" %}{{ icons::fire("h-6 w-6 text-accent") }}{% endif %} {% if card.icon == "coffee_bean" %}{{ icons::coffee_bean("h-6 w-6 text-accent") }}{% else if card.icon == "beaker" %}{{ icons::beaker("h-6 w-6 text-accent") }}{% else if card.icon == "map" %}{{ icons::map("h-6 w-6 text-accent") }}{% else if card.icon == "location" %}{{ icons::location("h-6 w-6 text-accent") }}{% else if card.icon == "fire" %}{{ icons::fire("h-6 w-6 text-accent") }}{% endif %}
<span class="text-lg font-bold text-text whitespace-nowrap">{{ card.value }}</span> <span class="text-lg font-bold text-text whitespace-nowrap"
<span class="text-sm font-medium text-accent">{{ card.label }}</span> >{{ card.value }}</span
>
<span class="text-sm font-medium text-accent"
>{{ card.label }}</span
>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
<button type="button" aria-label="Scroll right" <button
type="button"
aria-label="Scroll right"
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-right data-scroll-right
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</button> </button>
</chip-scroll> </chip-scroll>
{% else %} {% else %}
<div class="relative"> <div class="relative">
<div class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div
class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none"
aria-hidden="true"
>
{% for _ in 0..6 %} {% for _ in 0..6 %}
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
<div class="h-6 w-6 rounded-full bg-surface-alt"></div> <div class="h-6 w-6 rounded-full bg-surface-alt"></div>
<div class="h-5 w-16 rounded bg-surface-alt"></div> <div class="h-5 w-16 rounded bg-surface-alt"></div>
<div class="h-4 w-20 rounded bg-surface-alt"></div> <div class="h-4 w-20 rounded bg-surface-alt"></div>
@ -277,7 +373,9 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="absolute inset-0 z-10 flex items-center justify-center"> <div class="absolute inset-0 z-10 flex items-center justify-center">
<span class="text-lg font-semibold text-text-muted">No stats yet</span> <span class="text-lg font-semibold text-text-muted"
>No stats yet</span
>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -287,19 +385,27 @@
<section> <section>
<div class="flex items-center justify-between mb-5"> <div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Data</h2> <h2 class="text-lg font-semibold text-text">Data</h2>
<a href="/data" class="text-sm text-accent hover:text-accent-hover font-medium" <a
href="/data"
class="text-sm text-accent hover:text-accent-hover font-medium"
>View all data &rarr;</a >View all data &rarr;</a
> >
</div> </div>
{% if !stats.is_empty() %} {% if !stats.is_empty() %}
<chip-scroll class="relative block"> <chip-scroll class="relative block">
<button type="button" aria-label="Scroll left" <button
type="button"
aria-label="Scroll left"
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-left data-scroll-left
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})"
>
{{ icons::chevron_left("h-5 w-5") }} {{ icons::chevron_left("h-5 w-5") }}
</button> </button>
<div class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll> <div
class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-x-auto scroll-smooth scrollbar-hide"
data-chip-scroll
>
<a <a
href="/data?type=brews" href="/data?type=brews"
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
@ -321,7 +427,9 @@
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
> >
{{ icons::fire("h-6 w-6 text-accent") }} {{ icons::fire("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ stats.roasters }}</span> <span class="text-lg font-bold text-text"
>{{ stats.roasters }}</span
>
<span class="text-sm font-medium text-accent">Roasters</span> <span class="text-sm font-medium text-accent">Roasters</span>
</a> </a>
<a <a
@ -349,18 +457,26 @@
<span class="text-sm font-medium text-accent">Cafes</span> <span class="text-sm font-medium text-accent">Cafes</span>
</a> </a>
</div> </div>
<button type="button" aria-label="Scroll right" <button
type="button"
aria-label="Scroll right"
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-right data-scroll-right
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</button> </button>
</chip-scroll> </chip-scroll>
{% else %} {% else %}
<div class="relative"> <div class="relative">
<div class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div
class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none"
aria-hidden="true"
>
{% for _ in 0..6 %} {% for _ in 0..6 %}
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
<div class="h-6 w-6 rounded-full bg-surface-alt"></div> <div class="h-6 w-6 rounded-full bg-surface-alt"></div>
<div class="h-5 w-8 rounded bg-surface-alt"></div> <div class="h-5 w-8 rounded bg-surface-alt"></div>
<div class="h-4 w-16 rounded bg-surface-alt"></div> <div class="h-4 w-16 rounded bg-surface-alt"></div>

View file

@ -11,21 +11,32 @@
Sign in with a passkey to access the coffee log. Sign in with a passkey to access the coffee log.
</p> </p>
<div id="login-error" class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div> <div
id="login-error"
class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"
></div>
<div id="login-unsupported" class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800"> <div
This browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge). id="login-unsupported"
class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800"
>
This browser does not support passkeys. Please use a modern browser
(Chrome, Firefox, Safari, or Edge).
</div> </div>
<div class="mt-6"> <div class="mt-6">
<button <button
id="login-button" id="login-button"
type="button" type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed" > class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ icons::key("h-4 w-4") }} Sign in with Passkey {{ icons::key("h-4 w-4") }} Sign in with Passkey
</button> </button>
<div id="login-loading" class="mt-4 hidden text-center text-sm text-text-muted"> <div
id="login-loading"
class="mt-4 hidden text-center text-sm text-text-muted"
>
<p>Waiting for passkey...</p> <p>Waiting for passkey...</p>
</div> </div>
</div> </div>

View file

@ -8,13 +8,21 @@
<div class="rounded-lg border bg-surface p-6"> <div class="rounded-lg border bg-surface p-6">
<h1 class="text-2xl font-semibold text-accent">Register</h1> <h1 class="text-2xl font-semibold text-accent">Register</h1>
<p class="mt-2 text-sm text-text-secondary"> <p class="mt-2 text-sm text-text-secondary">
Create an account by registering a passkey. This will be used to sign in going forward. Create an account by registering a passkey. This will be used to sign in
going forward.
</p> </p>
<div id="register-error" class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div> <div
id="register-error"
class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"
></div>
<div id="register-unsupported" class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800"> <div
This browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge). id="register-unsupported"
class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800"
>
This browser does not support passkeys. Please use a modern browser
(Chrome, Firefox, Safari, or Edge).
</div> </div>
<div id="register-form" class="mt-6 flex flex-col gap-4"> <div id="register-form" class="mt-6 flex flex-col gap-4">
@ -44,15 +52,18 @@
<button <button
id="register-button" id="register-button"
type="button" type="button"
class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed" > class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ icons::key("h-4 w-4") }} Register Passkey {{ icons::key("h-4 w-4") }} Register Passkey
</button> </button>
<div id="register-loading" class="mt-2 hidden text-center text-sm text-text-muted"> <div
id="register-loading"
class="mt-2 hidden text-center text-sm text-text-muted"
>
<p>Follow the prompts from the browser or device...</p> <p>Follow the prompts from the browser or device...</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -3,14 +3,20 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ roast.name }}{% endblock %} {% block title %}Brewlog · {{ roast.name }}{% endblock %}
{% block og_title %}{{ roast.name }} — Brewlog{% endblock %} {% block og_title %}{{ roast.name }} — Brewlog{% endblock %}
{% block og_description %}{{ roast.name }} by {{ roast.roaster_name }} — {{ roast.origin }}{% endblock %} {% block og_description %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %} {{ roast.name }}
by {{ roast.roaster_name }} — {{ roast.origin }}
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% block content %} {% block content %}
<header class="flex items-start justify-between gap-4"> <header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1> <h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{{ roast.roaster_name }} · {{ roast.origin_flag }} {{ roast.origin }} · Added {{ roast.created_date }} {{ roast.roaster_name }} · {{ roast.origin_flag }} {{ roast.origin }} ·
Added {{ roast.created_date }}
</p> </p>
</div> </div>
</header> </header>
@ -26,9 +32,11 @@
{% if is_authenticated %} {% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt" class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this roast? This cannot be undone.') && @delete('/api/v1/roasts/{{ roast.id }}')"> data-on:click="confirm('Delete this roast? This cannot be undone.') && @delete('/api/v1/roasts/{{ roast.id }}')"
>
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>

View file

@ -3,14 +3,22 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ roaster.name }}{% endblock %} {% block title %}Brewlog · {{ roaster.name }}{% endblock %}
{% block og_title %}{{ roaster.name }} — Brewlog{% 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 og_description %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %} {{ roaster.name }}
— {{ roaster.country_flag }}
{{ roaster.country }}{% if let Some(c) = roaster.city %}, {{ c }}{% endif %}
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% block content %} {% block content %}
<header class="flex items-start justify-between gap-4"> <header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ roaster.name }}</h1> <h1 class="text-3xl font-semibold">{{ roaster.name }}</h1>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %} · {{ c }}{% endif %} · Added {{ roaster.created_date }} {{ roaster.country_flag }}
{{ roaster.country }}{% if let Some(c) = roaster.city %}· {{ c }}{% endif %}
· Added {{ roaster.created_date }}
</p> </p>
</div> </div>
</header> </header>
@ -35,7 +43,13 @@
<div> <div>
<dt class="text-text-muted">Website</dt> <dt class="text-text-muted">Website</dt>
<dd class="font-medium"> <dd class="font-medium">
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a> <a
href="{{ url }}"
target="_blank"
rel="noreferrer noopener"
class="text-accent hover:text-accent-hover transition"
>Visit Website</a
>
</dd> </dd>
</div> </div>
{% endif %} {% endif %}
@ -47,9 +61,11 @@
{% if is_authenticated %} {% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt" class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this roaster? This cannot be undone.') && @delete('/api/v1/roasters/{{ roaster.id }}')"> data-on:click="confirm('Delete this roaster? This cannot be undone.') && @delete('/api/v1/roasters/{{ roaster.id }}')"
>
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>

View file

@ -1,25 +1,33 @@
{% 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_title %}Stats — Brewlog{% endblock %}
{% block og_description %}Aggregated coffee data across origins, consumption, and brewing.{% endblock %} {% block og_description %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %} Aggregated coffee data across origins, consumption, and brewing.
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% block content %} {% block content %}
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<h1 class="text-3xl font-semibold">Stats</h1> <h1 class="text-3xl font-semibold">Stats</h1>
{% if !cache_age.is_empty() %} {% if !cache_age.is_empty() %}
<span class="shrink-0 text-xs text-text-muted pt-2">Updated {{ cache_age }}</span> <span class="shrink-0 text-xs text-text-muted pt-2"
>Updated {{ cache_age }}</span
>
{% endif %} {% endif %}
</div> </div>
<p class="max-w-2xl text-sm text-text-secondary">Aggregated coffee data across origins, consumption, and brewing.</p> <p class="max-w-2xl text-sm text-text-secondary">
Aggregated coffee data across origins, consumption, and brewing.
</p>
</header> </header>
{% if has_data %} {% if has_data %}
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
{% include "partials/tab_bar.html" %} {% include "partials/tab_bar.html" %}
<div id="stats-content"> <div id="stats-content">{{ content|safe }}</div>
{{ content|safe }}
</div>
</div> </div>
<section> <section>
@ -27,19 +35,31 @@
<h2 class="text-lg font-semibold text-text">Roast Stats</h2> <h2 class="text-lg font-semibold text-text">Roast Stats</h2>
</div> </div>
<div class="grid gap-3 sm:grid-cols-3"> <div class="grid gap-3 sm:grid-cols-3">
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::map("h-6 w-6 text-accent") }} {{ icons::map("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ roast_summary.unique_origins }}</span> <span class="text-lg font-bold text-text"
>{{ roast_summary.unique_origins }}</span
>
<span class="text-sm font-medium text-accent">Origins</span> <span class="text-sm font-medium text-accent">Origins</span>
</div> </div>
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::location("h-6 w-6 text-accent") }} {{ icons::location("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text truncate max-w-full">{% match roast_summary.top_origin %}{% when Some with (v) %}{{ v }}{% when None %}&mdash;{% endmatch %}</span> <span class="text-lg font-bold text-text truncate max-w-full"
>{% match roast_summary.top_origin %}{% when Some with (v) %}{{ v }}{% when None %}&mdash;{% endmatch %}</span
>
<span class="text-sm font-medium text-accent">Top Origin</span> <span class="text-sm font-medium text-accent">Top Origin</span>
</div> </div>
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::fire("h-6 w-6 text-accent") }} {{ icons::fire("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text truncate max-w-full">{% match roast_summary.top_roaster %}{% when Some with (v) %}{{ v }}{% when None %}&mdash;{% endmatch %}</span> <span class="text-lg font-bold text-text truncate max-w-full"
>{% match roast_summary.top_roaster %}{% when Some with (v) %}{{ v }}{% when None %}&mdash;{% endmatch %}</span
>
<span class="text-sm font-medium text-accent">Top Roaster</span> <span class="text-sm font-medium text-accent">Top Roaster</span>
</div> </div>
</div> </div>
@ -47,13 +67,17 @@
<div class="mt-5 grid gap-5 md:grid-cols-2"> <div class="mt-5 grid gap-5 md:grid-cols-2">
{% if !roast_summary.origin_counts.is_empty() %} {% if !roast_summary.origin_counts.is_empty() %}
<div> <div>
<h3 class="text-sm font-semibold text-text mb-3">Top 5 Origins</h3> <h3 class="text-sm font-semibold text-text mb-3">
Top 5 Origins
</h3>
{{ histogram::bar_chart(roast_summary.origin_counts, roast_summary.max_origin_count) }} {{ histogram::bar_chart(roast_summary.origin_counts, roast_summary.max_origin_count) }}
</div> </div>
{% endif %} {% endif %}
{% if !roast_summary.flavour_counts.is_empty() %} {% if !roast_summary.flavour_counts.is_empty() %}
<div> <div>
<h3 class="text-sm font-semibold text-text mb-3">Top 5 Flavours</h3> <h3 class="text-sm font-semibold text-text mb-3">
Top 5 Flavours
</h3>
{{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }} {{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }}
</div> </div>
{% endif %} {% endif %}
@ -66,24 +90,40 @@
<h2 class="text-lg font-semibold text-text">Consumption</h2> <h2 class="text-lg font-semibold text-text">Consumption</h2>
</div> </div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::coffee_bean("h-6 w-6 text-accent") }} {{ icons::coffee_bean("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ consumption_30d_weight }}</span> <span class="text-lg font-bold text-text"
>{{ consumption_30d_weight }}</span
>
<span class="text-sm font-medium text-accent">Last 30 Days</span> <span class="text-sm font-medium text-accent">Last 30 Days</span>
</div> </div>
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::coffee_bean("h-6 w-6 text-accent") }} {{ icons::coffee_bean("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ consumption_all_time_weight }}</span> <span class="text-lg font-bold text-text"
>{{ consumption_all_time_weight }}</span
>
<span class="text-sm font-medium text-accent">All Time</span> <span class="text-sm font-medium text-accent">All Time</span>
</div> </div>
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::beaker("h-6 w-6 text-accent") }} {{ icons::beaker("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ consumption.brews_last_30_days }}</span> <span class="text-lg font-bold text-text"
>{{ consumption.brews_last_30_days }}</span
>
<span class="text-sm font-medium text-accent">Brews (30d)</span> <span class="text-sm font-medium text-accent">Brews (30d)</span>
</div> </div>
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::beaker("h-6 w-6 text-accent") }} {{ icons::beaker("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ consumption.brews_all_time }}</span> <span class="text-lg font-bold text-text"
>{{ consumption.brews_all_time }}</span
>
<span class="text-sm font-medium text-accent">Brews (All Time)</span> <span class="text-sm font-medium text-accent">Brews (All Time)</span>
</div> </div>
</div> </div>
@ -98,13 +138,21 @@
{% if !brewing_summary.brewer_counts.is_empty() %} {% if !brewing_summary.brewer_counts.is_empty() %}
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h3 class="text-sm font-semibold text-text mb-4">Brewer Usage</h3> <h3 class="text-sm font-semibold text-text mb-4">Brewer Usage</h3>
<donut-chart data-icon="beaker" data-items="{% for item in brewing_summary.brewer_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"></donut-chart> <donut-chart
data-icon="beaker"
data-items="{% for item in brewing_summary.brewer_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"
></donut-chart>
</div> </div>
{% endif %} {% endif %}
{% if !brewing_summary.grinder_counts.is_empty() %} {% if !brewing_summary.grinder_counts.is_empty() %}
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h3 class="text-sm font-semibold text-text mb-4">Grinder Usage</h3> <h3 class="text-sm font-semibold text-text mb-4">
<donut-chart data-icon="grinder" data-items="{% for item in brewing_summary.grinder_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"></donut-chart> Grinder Usage
</h3>
<donut-chart
data-icon="grinder"
data-items="{% for item in brewing_summary.grinder_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"
></donut-chart>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -112,13 +160,17 @@
<div class="mt-5 grid gap-5 md:grid-cols-2"> <div class="mt-5 grid gap-5 md:grid-cols-2">
{% if !grinder_weights.is_empty() %} {% if !grinder_weights.is_empty() %}
<div> <div>
<h3 class="text-sm font-semibold text-text mb-3">Coffee per Grinder</h3> <h3 class="text-sm font-semibold text-text mb-3">
Coffee per Grinder
</h3>
{{ histogram::bar_chart_weight(grinder_weights, max_grinder_weight) }} {{ histogram::bar_chart_weight(grinder_weights, max_grinder_weight) }}
</div> </div>
{% endif %} {% endif %}
{% if !brewing_summary.brew_time_distribution.is_empty() %} {% if !brewing_summary.brew_time_distribution.is_empty() %}
<div> <div>
<h3 class="text-sm font-semibold text-text mb-3">Brew Time Distribution</h3> <h3 class="text-sm font-semibold text-text mb-3">
Brew Time Distribution
</h3>
{{ histogram::bar_chart(brewing_summary.brew_time_distribution, brewing_summary.max_brew_time_count) }} {{ histogram::bar_chart(brewing_summary.brew_time_distribution, brewing_summary.max_brew_time_count) }}
</div> </div>
{% endif %} {% endif %}
@ -126,13 +178,20 @@
</section> </section>
{% else %} {% else %}
<div class="relative"> <div class="relative">
<div class="flex flex-col gap-8 blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div
class="flex flex-col gap-8 blur-[2px] select-none pointer-events-none"
aria-hidden="true"
>
<!-- Placeholder: Roast Stats --> <!-- Placeholder: Roast Stats -->
<section> <section>
<div class="mb-5"><div class="h-5 w-28 rounded bg-surface-alt"></div></div> <div class="mb-5">
<div class="h-5 w-28 rounded bg-surface-alt"></div>
</div>
<div class="grid gap-3 sm:grid-cols-3"> <div class="grid gap-3 sm:grid-cols-3">
{% for _ in 0..3 %} {% for _ in 0..3 %}
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
<div class="h-6 w-6 rounded-full bg-surface-alt"></div> <div class="h-6 w-6 rounded-full bg-surface-alt"></div>
<div class="h-5 w-10 rounded bg-surface-alt"></div> <div class="h-5 w-10 rounded bg-surface-alt"></div>
<div class="h-4 w-16 rounded bg-surface-alt"></div> <div class="h-4 w-16 rounded bg-surface-alt"></div>
@ -142,10 +201,14 @@
</section> </section>
<!-- Placeholder: Consumption --> <!-- Placeholder: Consumption -->
<section> <section>
<div class="mb-5"><div class="h-5 w-32 rounded bg-surface-alt"></div></div> <div class="mb-5">
<div class="h-5 w-32 rounded bg-surface-alt"></div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
{% for _ in 0..4 %} {% for _ in 0..4 %}
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"> <div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
<div class="h-6 w-6 rounded-full bg-surface-alt"></div> <div class="h-6 w-6 rounded-full bg-surface-alt"></div>
<div class="h-5 w-12 rounded bg-surface-alt"></div> <div class="h-5 w-12 rounded bg-surface-alt"></div>
<div class="h-4 w-20 rounded bg-surface-alt"></div> <div class="h-4 w-20 rounded bg-surface-alt"></div>
@ -155,7 +218,9 @@
</section> </section>
<!-- Placeholder: Brewing charts --> <!-- Placeholder: Brewing charts -->
<section> <section>
<div class="mb-5"><div class="h-5 w-24 rounded bg-surface-alt"></div></div> <div class="mb-5">
<div class="h-5 w-24 rounded bg-surface-alt"></div>
</div>
<div class="grid gap-5 md:grid-cols-2"> <div class="grid gap-5 md:grid-cols-2">
{% for _ in 0..2 %} {% for _ in 0..2 %}
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">

View file

@ -1,4 +1,5 @@
{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %} {% block content %} {% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %}
{% block content %}
<div> <div>
<section <section
id="timeline-events" id="timeline-events"
@ -17,7 +18,10 @@
{% else %} {% else %}
<div class="timeline-list relative" id="timeline-items"> <div class="timeline-list relative" id="timeline-items">
{# Single central timeline line for all months #} {# Single central timeline line for all months #}
<div class="timeline-line absolute top-0 bottom-0 w-1 bg-border" aria-hidden="true"></div> <div
class="timeline-line absolute top-0 bottom-0 w-1 bg-border"
aria-hidden="true"
></div>
{% for month in months %}{% include "partials/timeline_month.html" %}{% endfor %} {% for month in months %}{% include "partials/timeline_month.html" %}{% endfor %}
</div> </div>
{% endif %} {% endif %}
@ -38,8 +42,16 @@
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
Load more Load more
</button> </button>
<p id="timeline-status" class="text-xs text-text-muted" hidden>Loading…</p> <p id="timeline-status" class="text-xs text-text-muted" hidden>
<p id="timeline-end" class="text-sm text-text-muted" style="{% if events.has_next() || months.is_empty() %}display: none{% endif %}">No more events.</p> Loading…
</p>
<p
id="timeline-end"
class="text-sm text-text-muted"
style="{% if events.has_next() || months.is_empty() %}display: none{% endif %}"
>
No more events.
</p>
<p id="timeline-error" class="text-sm text-red-600" hidden></p> <p id="timeline-error" class="text-sm text-red-600" hidden></p>
</div> </div>
<div id="timeline-sentinel" class="h-1"></div> <div id="timeline-sentinel" class="h-1"></div>
@ -47,88 +59,97 @@
</div> </div>
<script type="module"> <script type="module">
const loader = document.getElementById("timeline-loader") const loader = document.getElementById("timeline-loader");
const itemsContainer = document.getElementById("timeline-items") const itemsContainer = document.getElementById("timeline-items");
const loadMoreButton = document.getElementById("timeline-load-more") const loadMoreButton = document.getElementById("timeline-load-more");
const statusLine = document.getElementById("timeline-status") const statusLine = document.getElementById("timeline-status");
const endLine = document.getElementById("timeline-end") const endLine = document.getElementById("timeline-end");
const errorLine = document.getElementById("timeline-error") const errorLine = document.getElementById("timeline-error");
const sentinel = document.getElementById("timeline-sentinel") const sentinel = document.getElementById("timeline-sentinel");
const emptyState = document.querySelector('[data-role="timeline-empty-state"]') const emptyState = document.querySelector(
const section = document.getElementById("timeline-events") '[data-role="timeline-empty-state"]',
);
const section = document.getElementById("timeline-events");
if (loader && sentinel) { if (loader && sentinel) {
let nextUrl = loader.dataset.nextUrl || "" let nextUrl = loader.dataset.nextUrl || "";
let hasMorePages = loader.dataset.hasMore === "true" let hasMorePages = loader.dataset.hasMore === "true";
let loading = false let loading = false;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries.some((entry) => entry.isIntersecting) && hasMorePages && nextUrl) { if (
void loadMore() entries.some((entry) => entry.isIntersecting) &&
hasMorePages &&
nextUrl
) {
void loadMore();
} }
}, },
{ rootMargin: "200px" } { rootMargin: "200px" },
) );
const setHasMore = (hasMore, url) => { const setHasMore = (hasMore, url) => {
hasMorePages = hasMore hasMorePages = hasMore;
nextUrl = url || "" nextUrl = url || "";
loader.dataset.hasMore = hasMore ? "true" : "false" loader.dataset.hasMore = hasMore ? "true" : "false";
loader.dataset.nextUrl = nextUrl loader.dataset.nextUrl = nextUrl;
if (!hasMore) { if (!hasMore) {
if (loadMoreButton) loadMoreButton.style.display = "none" if (loadMoreButton) loadMoreButton.style.display = "none";
if (endLine) endLine.style.display = "" if (endLine) endLine.style.display = "";
observer.disconnect() observer.disconnect();
}
} }
};
const ensureItemsContainer = () => { const ensureItemsContainer = () => {
if (itemsContainer) return itemsContainer if (itemsContainer) return itemsContainer;
// First load on a previously empty timeline: create the container // First load on a previously empty timeline: create the container
const container = document.createElement("div") const container = document.createElement("div");
container.className = "timeline-list relative" container.className = "timeline-list relative";
container.id = "timeline-items" container.id = "timeline-items";
const line = document.createElement("div") const line = document.createElement("div");
line.className = "timeline-line absolute top-0 bottom-0 w-1 bg-border" line.className = "timeline-line absolute top-0 bottom-0 w-1 bg-border";
line.setAttribute("aria-hidden", "true") line.setAttribute("aria-hidden", "true");
container.appendChild(line) container.appendChild(line);
section.insertBefore(container, loader) section.insertBefore(container, loader);
return container return container;
} };
const appendMonths = (chunk) => { const appendMonths = (chunk) => {
const monthsFragment = chunk.querySelector("[data-chunk-months]") const monthsFragment = chunk.querySelector("[data-chunk-months]");
if (!monthsFragment) return if (!monthsFragment) return;
const container = ensureItemsContainer() const container = ensureItemsContainer();
for (const node of Array.from(monthsFragment.children)) { for (const node of Array.from(monthsFragment.children)) {
// Skip duplicate month headings (same month spanning a page boundary) // Skip duplicate month headings (same month spanning a page boundary)
if (node.hasAttribute("data-timeline-month") && document.getElementById(node.id)) { if (
continue node.hasAttribute("data-timeline-month") &&
} document.getElementById(node.id)
container.appendChild(node) ) {
continue;
} }
container.appendChild(node);
} }
};
const clearEmptyState = () => { const clearEmptyState = () => {
if (emptyState && !emptyState.hidden) { if (emptyState && !emptyState.hidden) {
emptyState.hidden = true emptyState.hidden = true;
loader.dataset.empty = "false" loader.dataset.empty = "false";
}
} }
};
const loadMore = async () => { const loadMore = async () => {
if (loading || !hasMorePages || !nextUrl) return if (loading || !hasMorePages || !nextUrl) return;
loading = true loading = true;
if (statusLine) { if (statusLine) {
statusLine.hidden = false statusLine.hidden = false;
} }
if (errorLine) { if (errorLine) {
errorLine.hidden = true errorLine.hidden = true;
} }
try { try {
@ -137,46 +158,47 @@
"X-Requested-With": "fetch", "X-Requested-With": "fetch",
"datastar-request": "true", "datastar-request": "true",
}, },
}) });
if (!response.ok) { if (!response.ok) {
throw new Error(`Unexpected response: ${response.status}`) throw new Error(`Unexpected response: ${response.status}`);
} }
const html = await response.text() const html = await response.text();
const template = document.createElement("template") const template = document.createElement("template");
template.innerHTML = html.trim() template.innerHTML = html.trim();
const chunk = template.content.querySelector("[data-timeline-chunk]") const chunk = template.content.querySelector("[data-timeline-chunk]");
if (!chunk) { if (!chunk) {
throw new Error("Invalid timeline chunk payload") throw new Error("Invalid timeline chunk payload");
} }
appendMonths(chunk) appendMonths(chunk);
clearEmptyState() clearEmptyState();
const hasMore = chunk.dataset.hasMore === "true" const hasMore = chunk.dataset.hasMore === "true";
const url = chunk.dataset.nextUrl || "" const url = chunk.dataset.nextUrl || "";
setHasMore(hasMore, url) setHasMore(hasMore, url);
} catch (error) { } catch (error) {
console.error(error) console.error(error);
if (errorLine) { if (errorLine) {
errorLine.textContent = "Failed to load more events. Please try again." errorLine.textContent =
errorLine.hidden = false "Failed to load more events. Please try again.";
errorLine.hidden = false;
} }
} finally { } finally {
loading = false loading = false;
if (statusLine) { if (statusLine) {
statusLine.hidden = true statusLine.hidden = true;
}
} }
} }
};
if (loadMoreButton) { if (loadMoreButton) {
loadMoreButton.addEventListener("click", () => { loadMoreButton.addEventListener("click", () => {
void loadMore() void loadMore();
}) });
} }
if (hasMorePages && loader.dataset.empty !== "true") { if (hasMorePages && loader.dataset.empty !== "true") {
observer.observe(sentinel) observer.observe(sentinel);
} }
} }
@ -184,36 +206,36 @@
const stickyObserver = new IntersectionObserver( const stickyObserver = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
entry.target.classList.toggle("is-stuck", !entry.isIntersecting) entry.target.classList.toggle("is-stuck", !entry.isIntersecting);
}) });
}, },
{ rootMargin: "-1px 0px 0px 0px", threshold: 1 } { rootMargin: "-1px 0px 0px 0px", threshold: 1 },
) );
// Use MutationObserver to watch for new month headings being added // Use MutationObserver to watch for new month headings being added
const timelineItems = document.getElementById("timeline-items") const timelineItems = document.getElementById("timeline-items");
if (timelineItems) { if (timelineItems) {
const mutationObserver = new MutationObserver((mutations) => { const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => { mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) { if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches?.(".timeline-heading")) { if (node.matches?.(".timeline-heading")) {
stickyObserver.observe(node) stickyObserver.observe(node);
} }
const heading = node.querySelector?.(".timeline-heading") const heading = node.querySelector?.(".timeline-heading");
if (heading) { if (heading) {
stickyObserver.observe(heading) stickyObserver.observe(heading);
} }
} }
}) });
}) });
}) });
mutationObserver.observe(timelineItems, { childList: true }) mutationObserver.observe(timelineItems, { childList: true });
} }
// Observe existing headings // Observe existing headings
document.querySelectorAll(".timeline-heading").forEach((heading) => { document.querySelectorAll(".timeline-heading").forEach((heading) => {
stickyObserver.observe(heading) stickyObserver.observe(heading);
}) });
</script> </script>
{% endblock %} {% endblock %}

View file

@ -1,25 +1,39 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% macro card(bag, is_authenticated) %} {% macro card(bag, is_authenticated) %}
<a href="/bags/{{ bag.id }}" id="bag-card-{{ bag.id }}" class="relative w-[45vw] md:w-[200px] h-[200px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col transition hover:border-accent/40"> <a
href="/bags/{{ bag.id }}"
id="bag-card-{{ bag.id }}"
class="relative w-[45vw] md:w-[200px] h-[200px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col transition hover:border-accent/40"
>
<div class="flex-1"> <div class="flex-1">
<span class="block font-semibold text-text truncate">{{ bag.roast_name }}</span> <span class="block font-semibold text-text truncate"
<p class="mt-1 text-sm text-text-muted truncate">{{ bag.roaster_name }}</p> >{{ bag.roast_name }}</span
>
<p class="mt-1 text-sm text-text-muted truncate">
{{ bag.roaster_name }}
</p>
</div> </div>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div class="w-full"> <div class="w-full">
<div class="h-2 rounded-full bg-surface-alt overflow-hidden"> <div class="h-2 rounded-full bg-surface-alt overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div> <div
class="h-full rounded-full bg-accent"
style="width: {{ bag.used_percent }}%"
></div>
</div> </div>
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">{{ bag.remaining }} / {{ bag.amount }}</p> <p class="mt-1 text-text-muted" style="font-size: 0.7rem">
{{ bag.remaining }} / {{ bag.amount }}
</p>
</div> </div>
</div> </div>
{% if is_authenticated %} {% if is_authenticated %}
<div class="relative z-10 mt-3"> <div class="relative z-10 mt-3">
<span onclick="event.preventDefault(); window.location.href='/add?type=brew&bag_id={{ bag.id }}';" <span
onclick="event.preventDefault(); window.location.href='/add?type=brew&bag_id={{ bag.id }}';"
title="Brew" title="Brew"
class="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt cursor-pointer"> class="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt cursor-pointer"
{{ icons::beaker("h-4 w-4") }} >
Brew {{ icons::beaker("h-4 w-4") }} Brew
</span> </span>
</div> </div>
{% endif %} {% endif %}

View file

@ -1,23 +1,41 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% macro card(brew, is_authenticated) %} {% macro card(brew, is_authenticated) %}
<a href="/brews/{{ brew.id }}" class="relative w-[45vw] md:w-[200px] h-[279px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col transition hover:border-accent/40"> <a
href="/brews/{{ brew.id }}"
class="relative w-[45vw] md:w-[200px] h-[279px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col transition hover:border-accent/40"
>
<div class="h-[3.25rem] overflow-hidden"> <div class="h-[3.25rem] overflow-hidden">
<span class="block font-semibold text-text truncate">{{ brew.roast_name }}</span> <span class="block font-semibold text-text truncate"
<p class="mt-0.5 text-xs text-text-muted">{{ brew.relative_date_label }}</p> >{{ brew.roast_name }}</span
>
<p class="mt-0.5 text-xs text-text-muted">
{{ brew.relative_date_label }}
</p>
</div> </div>
<div class="mt-1 flex-1 space-y-1 text-text-secondary" style="font-size: 0.8125rem"> <div
<p>{{ brew.coffee_weight }} &middot; {{ brew.water_volume }}{% if let Some(bt) = brew.brew_time %} &middot; {{ bt }}{% endif %}</p> class="mt-1 flex-1 space-y-1 text-text-secondary"
style="font-size: 0.8125rem"
>
<p>
{{ brew.coffee_weight }} &middot;
{{ brew.water_volume }}{% if let Some(bt) = brew.brew_time %}
&middot; {{ bt }}
{% endif %}
</p>
<p>{{ brew.grinder_model }} &middot; {{ brew.grind_setting }}</p> <p>{{ brew.grinder_model }} &middot; {{ brew.grind_setting }}</p>
<p>{{ brew.brewer_name }}</p> <p>{{ brew.brewer_name }}</p>
{% if let Some(fp) = brew.filter_paper_name %}<p>{{ fp }}</p>{% endif %} {% if let Some(fp) = brew.filter_paper_name %}<p>{{ fp }}</p>{% endif %}
{% if !brew.quick_notes.is_empty() %}<p class="truncate">{{ brew.quick_notes_label }}</p>{% endif %} {% if !brew.quick_notes.is_empty() %}
<p class="truncate">{{ brew.quick_notes_label }}</p>
{% endif %}
</div> </div>
{% if is_authenticated %} {% if is_authenticated %}
<div class="relative z-10 mt-3"> <div class="relative z-10 mt-3">
<span onclick="event.preventDefault(); window.location.href='{{ brew.brew_again_url() }}';" <span
class="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt cursor-pointer"> onclick="event.preventDefault(); window.location.href='{{ brew.brew_again_url() }}';"
{{ icons::beaker("h-4 w-4") }} class="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt cursor-pointer"
Brew Again >
{{ icons::beaker("h-4 w-4") }} Brew Again
</span> </span>
</div> </div>
{% endif %} {% endif %}

View file

@ -1,9 +1,14 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% macro share_button() %} {% macro share_button() %}
<button onclick="shareLink(this)" class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium text-text-muted transition hover:bg-surface-alt hover:text-text shrink-0"> <button
onclick="shareLink(this)"
class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium text-text-muted transition hover:bg-surface-alt hover:text-text shrink-0"
>
<span class="share-icon">{{ icons::clipboard("h-4 w-4 shrink-0") }}</span> <span class="share-icon">{{ icons::clipboard("h-4 w-4 shrink-0") }}</span>
<span class="share-icon-check hidden">{{ icons::check("h-4 w-4 shrink-0") }}</span> <span class="share-icon-check hidden"
>{{ icons::check("h-4 w-4 shrink-0") }}</span
>
<span class="share-label">Share</span> <span class="share-label">Share</span>
</button> </button>
{% endmacro %} {% endmacro %}
@ -12,13 +17,13 @@
<script> <script>
const shareLink = (btn) => { const shareLink = (btn) => {
navigator.clipboard.writeText(window.location.href).then(() => { navigator.clipboard.writeText(window.location.href).then(() => {
btn.querySelector('.share-icon').classList.add('hidden'); btn.querySelector(".share-icon").classList.add("hidden");
btn.querySelector('.share-icon-check').classList.remove('hidden'); btn.querySelector(".share-icon-check").classList.remove("hidden");
btn.querySelector('.share-label').textContent = 'Copied'; btn.querySelector(".share-label").textContent = "Copied";
setTimeout(() => { setTimeout(() => {
btn.querySelector('.share-icon').classList.remove('hidden'); btn.querySelector(".share-icon").classList.remove("hidden");
btn.querySelector('.share-icon-check').classList.add('hidden'); btn.querySelector(".share-icon-check").classList.add("hidden");
btn.querySelector('.share-label').textContent = 'Share'; btn.querySelector(".share-label").textContent = "Share";
}, 2000); }, 2000);
}); });
}; };
@ -76,15 +81,22 @@ const shareLink = (btn) => {
{% macro map_with_legend_1(map_countries, map_max, label1, opacity1) %} {% macro map_with_legend_1(map_countries, map_max, label1, opacity1) %}
{% if !map_countries.is_empty() %} {% if !map_countries.is_empty() %}
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center"> <div
class="relative rounded-lg border bg-surface overflow-hidden flex items-center"
>
<world-map <world-map
class="block w-full" class="block w-full"
data-countries="{{ map_countries }}" data-countries="{{ map_countries }}"
data-max="{{ map_max }}" data-max="{{ map_max }}"
></world-map> ></world-map>
<div class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted"> <div
class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted"
>
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"></span> {{ label1 }} <span
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"
></span>
{{ label1 }}
</span> </span>
</div> </div>
</div> </div>
@ -93,18 +105,28 @@ const shareLink = (btn) => {
{% macro map_with_legend_2(map_countries, map_max, label1, opacity1, label2, opacity2) %} {% macro map_with_legend_2(map_countries, map_max, label1, opacity1, label2, opacity2) %}
{% if !map_countries.is_empty() %} {% if !map_countries.is_empty() %}
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center"> <div
class="relative rounded-lg border bg-surface overflow-hidden flex items-center"
>
<world-map <world-map
class="block w-full" class="block w-full"
data-countries="{{ map_countries }}" data-countries="{{ map_countries }}"
data-max="{{ map_max }}" data-max="{{ map_max }}"
></world-map> ></world-map>
<div class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted"> <div
class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted"
>
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"></span> {{ label1 }} <span
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"
></span>
{{ label1 }}
</span> </span>
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"></span> {{ label2 }} <span
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"
></span>
{{ label2 }}
</span> </span>
</div> </div>
</div> </div>
@ -113,21 +135,34 @@ const shareLink = (btn) => {
{% macro map_with_legend_3(map_countries, map_max, label1, opacity1, label2, opacity2, label3, opacity3) %} {% macro map_with_legend_3(map_countries, map_max, label1, opacity1, label2, opacity2, label3, opacity3) %}
{% if !map_countries.is_empty() %} {% if !map_countries.is_empty() %}
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center"> <div
class="relative rounded-lg border bg-surface overflow-hidden flex items-center"
>
<world-map <world-map
class="block w-full" class="block w-full"
data-countries="{{ map_countries }}" data-countries="{{ map_countries }}"
data-max="{{ map_max }}" data-max="{{ map_max }}"
></world-map> ></world-map>
<div class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted"> <div
class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted"
>
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"></span> {{ label1 }} <span
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"
></span>
{{ label1 }}
</span> </span>
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"></span> {{ label2 }} <span
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"
></span>
{{ label2 }}
</span> </span>
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity3 }}"></span> {{ label3 }} <span
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity3 }}"
></span>
{{ label3 }}
</span> </span>
</div> </div>
</div> </div>
@ -158,7 +193,13 @@ const shareLink = (btn) => {
<div> <div>
<dt class="text-text-muted">Website</dt> <dt class="text-text-muted">Website</dt>
<dd class="font-medium"> <dd class="font-medium">
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a> <a
href="{{ url }}"
target="_blank"
rel="noreferrer noopener"
class="text-accent hover:text-accent-hover transition"
>Visit Website</a
>
</dd> </dd>
</div> </div>
{% endif %} {% endif %}

View file

@ -1,9 +1,21 @@
<!-- Hidden inputs for submission (always present, bound to signals) --> <!-- Hidden inputs for submission (always present, bound to signals) -->
<input type="hidden" name="matched_roast_id" data-attr:value="$_matchedRoastId" /> <input
type="hidden"
name="matched_roast_id"
data-attr:value="$_matchedRoastId"
/>
<input type="hidden" name="roaster_name" data-attr:value="$_roasterName" /> <input type="hidden" name="roaster_name" data-attr:value="$_roasterName" />
<input type="hidden" name="roaster_country" data-attr:value="$_roasterCountry" /> <input
type="hidden"
name="roaster_country"
data-attr:value="$_roasterCountry"
/>
<input type="hidden" name="roaster_city" data-attr:value="$_roasterCity" /> <input type="hidden" name="roaster_city" data-attr:value="$_roasterCity" />
<input type="hidden" name="roaster_homepage" data-attr:value="$_roasterHomepage" /> <input
type="hidden"
name="roaster_homepage"
data-attr:value="$_roasterHomepage"
/>
<input type="hidden" name="roast_name" data-attr:value="$_roastName" /> <input type="hidden" name="roast_name" data-attr:value="$_roastName" />
<input type="hidden" name="origin" data-attr:value="$_origin" /> <input type="hidden" name="origin" data-attr:value="$_origin" />
<input type="hidden" name="region" data-attr:value="$_region" /> <input type="hidden" name="region" data-attr:value="$_region" />
@ -14,36 +26,71 @@
<!-- Roaster: card (matched) --> <!-- Roaster: card (matched) -->
<div data-show="$_matchedRoasterId"> <div data-show="$_matchedRoasterId">
<h3 class="text-base font-semibold text-text">Roaster</h3> <h3 class="text-base font-semibold text-text">Roaster</h3>
<div class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer" data-on:click="$_matchedRoasterId = ''; $_matchedRoastId = ''"> <div
class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer"
data-on:click="$_matchedRoasterId = ''; $_matchedRoastId = ''"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{ icons::fire("h-4 w-4 text-accent shrink-0") }} {{ icons::fire("h-4 w-4 text-accent shrink-0") }}
<span class="font-medium text-text" data-text="$_roasterName"></span> <span class="font-medium text-text" data-text="$_roasterName"></span>
<span class="text-xs text-text-muted" data-show="$_roasterCountry" data-text="$_roasterCountry" style="display:none"></span> <span
class="text-xs text-text-muted"
data-show="$_roasterCountry"
data-text="$_roasterCountry"
style="display:none"
></span>
</div> </div>
<button type="button" class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text" <button
data-on:click="$_matchedRoasterId = ''; $_matchedRoastId = ''">{{ icons::pencil("h-3 w-3") }} Change</button> type="button"
class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
data-on:click="$_matchedRoasterId = ''; $_matchedRoastId = ''"
>
{{ icons::pencil("h-3 w-3") }} Change
</button>
</div> </div>
</div> </div>
<!-- Roaster: form (not matched) --> <!-- Roaster: form (not matched) -->
<div data-show="!$_matchedRoasterId" style="display:none"> <div data-show="!$_matchedRoasterId" style="display:none">
<h3 class="text-base font-semibold text-text">Roaster</h3> <h3 class="text-base font-semibold text-text">Roaster</h3>
<p class="mt-1 text-sm text-text-secondary">If this roaster already exists, it will be matched automatically.</p> <p class="mt-1 text-sm text-text-secondary">
If this roaster already exists, it will be matched automatically.
</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2"> <div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Name *</span> <span class="text-text">Name *</span>
<input type="text" class="input-field" placeholder="Example Coffee Roasters" data-bind:_roaster-name /> <input
type="text"
class="input-field"
placeholder="Example Coffee Roasters"
data-bind:_roaster-name
/>
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Country *</span> <span class="text-text">Country *</span>
<input type="text" class="input-field" placeholder="United States" data-bind:_roaster-country /> <input
type="text"
class="input-field"
placeholder="United States"
data-bind:_roaster-country
/>
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">City</span> <span class="text-text">City</span>
<input type="text" class="input-field" placeholder="Portland" data-bind:_roaster-city /> <input
type="text"
class="input-field"
placeholder="Portland"
data-bind:_roaster-city
/>
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Homepage</span> <span class="text-text">Homepage</span>
<input type="url" class="input-field" placeholder="https://example.coffee" data-bind:_roaster-homepage /> <input
type="url"
class="input-field"
placeholder="https://example.coffee"
data-bind:_roaster-homepage
/>
</label> </label>
</div> </div>
</div> </div>
@ -51,64 +98,131 @@
<!-- Roast: card (matched) --> <!-- Roast: card (matched) -->
<div data-show="$_matchedRoastId"> <div data-show="$_matchedRoastId">
<h3 class="text-base font-semibold text-text">Roast</h3> <h3 class="text-base font-semibold text-text">Roast</h3>
<div class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer" data-on:click="$_matchedRoastId = ''"> <div
class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer"
data-on:click="$_matchedRoastId = ''"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{ icons::coffee_bean("h-4 w-4 text-accent shrink-0") }} {{ icons::coffee_bean("h-4 w-4 text-accent shrink-0") }}
<span class="font-medium text-text" data-text="$_roastName"></span> <span class="font-medium text-text" data-text="$_roastName"></span>
<span class="text-xs text-text-muted" data-show="$_origin" data-text="$_origin" style="display:none"></span> <span
class="text-xs text-text-muted"
data-show="$_origin"
data-text="$_origin"
style="display:none"
></span>
</div> </div>
<button type="button" class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text" <button
data-on:click="$_matchedRoastId = ''">{{ icons::pencil("h-3 w-3") }} Change</button> type="button"
class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
data-on:click="$_matchedRoastId = ''"
>
{{ icons::pencil("h-3 w-3") }} Change
</button>
</div> </div>
</div> </div>
<!-- Roast: form (not matched) --> <!-- Roast: form (not matched) -->
<div data-show="!$_matchedRoastId" style="display:none"> <div data-show="!$_matchedRoastId" style="display:none">
<h3 class="text-base font-semibold text-text">Roast</h3> <h3 class="text-base font-semibold text-text">Roast</h3>
<p class="mt-1 text-sm text-text-secondary">Details about this specific coffee.</p> <p class="mt-1 text-sm text-text-secondary">
Details about this specific coffee.
</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2"> <div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Roast Name *</span> <span class="text-text">Roast Name *</span>
<input type="text" class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_roast-name /> <input
type="text"
class="input-field"
placeholder="Ethiopia Yirgacheffe"
data-bind:_roast-name
/>
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Origin *</span> <span class="text-text">Origin *</span>
<input type="text" class="input-field" placeholder="Ethiopia" data-bind:_origin /> <input
type="text"
class="input-field"
placeholder="Ethiopia"
data-bind:_origin
/>
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Region *</span> <span class="text-text">Region *</span>
<input type="text" class="input-field" placeholder="Guji" data-bind:_region /> <input
type="text"
class="input-field"
placeholder="Guji"
data-bind:_region
/>
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Producer *</span> <span class="text-text">Producer *</span>
<input type="text" class="input-field" placeholder="Chelbesa Cooperative" data-bind:_producer /> <input
type="text"
class="input-field"
placeholder="Chelbesa Cooperative"
data-bind:_producer
/>
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Process *</span> <span class="text-text">Process *</span>
<input type="text" class="input-field" placeholder="Washed" data-bind:_process /> <input
type="text"
class="input-field"
placeholder="Washed"
data-bind:_process
/>
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Tasting Notes * (comma separated)</span> <span class="text-text">Tasting Notes * (comma separated)</span>
<textarea rows="2" class="input-field" placeholder="Blueberry, Jasmine" data-bind:_tasting-notes></textarea> <textarea
rows="2"
class="input-field"
placeholder="Blueberry, Jasmine"
data-bind:_tasting-notes
></textarea>
</label> </label>
</div> </div>
</div> </div>
<div> <div>
<label class="inline-flex items-center gap-2 text-sm cursor-pointer"> <label class="inline-flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="open_bag" value="true" class="accent-orange-700" data-bind:_open-bag /> <input
type="checkbox"
name="open_bag"
value="true"
class="accent-orange-700"
data-bind:_open-bag
/>
<span class="font-semibold text-text">Open a bag of this coffee</span> <span class="font-semibold text-text">Open a bag of this coffee</span>
</label> </label>
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2"> <div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Amount (grams)</span> <span class="text-text">Amount (grams)</span>
<input type="number" name="bag_amount" min="1" step="any" class="input-field" placeholder="250" data-bind:_bag-amount /> <input
type="number"
name="bag_amount"
min="1"
step="any"
class="input-field"
placeholder="250"
data-bind:_bag-amount
/>
</label> </label>
</div> </div>
</div> </div>
<p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p> <p
<div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-accent"> data-show="$_scanError"
{{ icons::spinner("h-5 w-5") }} data-text="$_scanError"
Saving&hellip; style="display:none"
class="text-sm text-red-600"
></p>
<div
data-show="$_scanSubmitting"
style="display:none"
class="flex items-center gap-3 text-sm text-accent"
>
{{ icons::spinner("h-5 w-5") }} Saving&hellip;
</div> </div>
<div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2"> <div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
<button <button
@ -130,7 +244,8 @@
<button <button
type="submit" type="submit"
data-show="$_matchedRoasterId && !$_matchedRoastId" data-show="$_matchedRoasterId && !$_matchedRoastId"
class="inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover" style="display:none" class="inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
style="display:none"
> >
{{ icons::plus("h-4 w-4") }} Save Roast {{ icons::plus("h-4 w-4") }} Save Roast
</button> </button>
@ -138,13 +253,18 @@
<button <button
type="submit" type="submit"
data-show="$_matchedRoastId && $_openBag" data-show="$_matchedRoastId && $_openBag"
class="inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover" style="display:none" class="inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
style="display:none"
> >
{{ icons::bag("h-4 w-4") }} Open Bag {{ icons::bag("h-4 w-4") }} Open Bag
</button> </button>
<!-- Both matched + no bag: nothing to do --> <!-- Both matched + no bag: nothing to do -->
<p data-show="$_matchedRoastId && !$_openBag" style="display:none" <p
class="text-sm text-text-secondary"> data-show="$_matchedRoastId && !$_openBag"
This roast already exists. Check &ldquo;Open a bag&rdquo; above to add a new bag. style="display:none"
class="text-sm text-text-secondary"
>
This roast already exists. Check &ldquo;Open a bag&rdquo; above to add a new
bag.
</p> </p>
</div> </div>

View file

@ -3,11 +3,19 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{% for item in items %} {% for item in items %}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate">{{ item.0 }}</span> <span
class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate"
>{{ item.0 }}</span
>
<div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden"> <div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden">
<div class="h-full rounded bg-accent" style="width: {{ item.1 * 100 / max_count }}%"></div> <div
class="h-full rounded bg-accent"
style="width: {{ item.1 * 100 / max_count }}%"
></div>
</div> </div>
<span class="w-6 shrink-0 text-xs text-text-muted text-right">{{ item.1 }}</span> <span class="w-6 shrink-0 text-xs text-text-muted text-right"
>{{ item.1 }}</span
>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -19,11 +27,19 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{% for item in items %} {% for item in items %}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate">{{ item.0 }}</span> <span
class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate"
>{{ item.0 }}</span
>
<div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden"> <div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden">
<div class="h-full rounded bg-accent" style="width: {{ item.1 * 100.0 / max_weight }}%"></div> <div
class="h-full rounded bg-accent"
style="width: {{ item.1 * 100.0 / max_weight }}%"
></div>
</div> </div>
<span class="w-14 shrink-0 text-xs text-text-muted text-right">{{ item.2 }}</span> <span class="w-14 shrink-0 text-xs text-text-muted text-right"
>{{ item.2 }}</span
>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -1,218 +1,525 @@
{% macro plus(class) %} {% macro plus(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M11.25 3.75a1.25 1.25 0 1 0-2.5 0v4.5h-4.5a1.25 1.25 0 1 0 0 2.5h4.5v4.5a1.25 1.25 0 1 0 2.5 0v-4.5h4.5a1.25 1.25 0 1 0 0-2.5h-4.5v-4.5Z" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M11.25 3.75a1.25 1.25 0 1 0-2.5 0v4.5h-4.5a1.25 1.25 0 1 0 0 2.5h4.5v4.5a1.25 1.25 0 1 0 2.5 0v-4.5h4.5a1.25 1.25 0 1 0 0-2.5h-4.5v-4.5Z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro camera(class) %} {% macro camera(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro spinner(class) %} {% macro spinner(class) %}
<svg class="{{ class }} animate-spin text-accent" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> class="{{ class }} animate-spin text-accent"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro delete(class) %} {% macro delete(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro external_link(class) %} {% macro external_link(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z" clip-rule="evenodd" /> class="{{ class }}"
<path d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z" /> viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
clip-rule="evenodd"
/>
<path
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro location(class) %} {% macro location(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003ZM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003ZM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro map(class) %} {% macro map(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M8.157 2.176a1.5 1.5 0 0 0-1.147 0l-4.084 1.69A1.5 1.5 0 0 0 2 5.251v10.877a1.5 1.5 0 0 0 2.074 1.386l3.51-1.453 4.26 1.763a1.5 1.5 0 0 0 1.146 0l4.083-1.69A1.5 1.5 0 0 0 18 14.748V3.873a1.5 1.5 0 0 0-2.073-1.386l-3.51 1.452-4.26-1.763ZM7.58 5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 7.58 5Zm5.59 2.75a.75.75 0 0 0-1.5 0v6.5a.75.75 0 0 0 1.5 0v-6.5Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.157 2.176a1.5 1.5 0 0 0-1.147 0l-4.084 1.69A1.5 1.5 0 0 0 2 5.251v10.877a1.5 1.5 0 0 0 2.074 1.386l3.51-1.453 4.26 1.763a1.5 1.5 0 0 0 1.146 0l4.083-1.69A1.5 1.5 0 0 0 18 14.748V3.873a1.5 1.5 0 0 0-2.073-1.386l-3.51 1.452-4.26-1.763ZM7.58 5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 7.58 5Zm5.59 2.75a.75.75 0 0 0-1.5 0v6.5a.75.75 0 0 0 1.5 0v-6.5Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro refresh(class) %} {% macro refresh(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro bag(class) %} {% macro bag(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M6 5v1H4.667a1.75 1.75 0 0 0-1.743 1.598l-.826 9.5A1.75 1.75 0 0 0 3.84 19H16.16a1.75 1.75 0 0 0 1.743-1.902l-.826-9.5A1.75 1.75 0 0 0 15.333 6H14V5a4 4 0 0 0-8 0Zm4-2.5A2.5 2.5 0 0 0 7.5 5v1h5V5A2.5 2.5 0 0 0 10 2.5ZM7.5 10a2.5 2.5 0 0 0 5 0V8.75a.75.75 0 0 1 1.5 0V10a4 4 0 0 1-8 0V8.75a.75.75 0 0 1 1.5 0V10Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M6 5v1H4.667a1.75 1.75 0 0 0-1.743 1.598l-.826 9.5A1.75 1.75 0 0 0 3.84 19H16.16a1.75 1.75 0 0 0 1.743-1.902l-.826-9.5A1.75 1.75 0 0 0 15.333 6H14V5a4 4 0 0 0-8 0Zm4-2.5A2.5 2.5 0 0 0 7.5 5v1h5V5A2.5 2.5 0 0 0 10 2.5ZM7.5 10a2.5 2.5 0 0 0 5 0V8.75a.75.75 0 0 1 1.5 0V10a4 4 0 0 1-8 0V8.75a.75.75 0 0 1 1.5 0V10Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro cup(class) %} {% macro cup(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M7.25 2.75a.75.75 0 0 0-1.5 0C5.75 3.9 6.4 4.8 7 5.4c.35.35.5.7.5 1.1h-1a2.1 2.1 0 0 0-.8-1.6c-.45-.4-.95-1.1-.95-2.15ZM10.75 2.75a.75.75 0 0 0-1.5 0c0 1.15.65 2.05 1.25 2.65.35.35.5.7.5 1.1h-1a2.1 2.1 0 0 0-.8-1.6c-.45-.4-.95-1.1-.95-2.15Z" /> class="{{ class }}"
<path fill-rule="evenodd" d="M2.5 8.5A1.5 1.5 0 0 1 4 7h10a1.5 1.5 0 0 1 1.5 1.5v.5h.5a2.5 2.5 0 0 1 0 5h-.5v.5A3.5 3.5 0 0 1 12 18H6a3.5 3.5 0 0 1-3.5-3.5v-6Zm13 2v3h.5a1 1 0 1 0 0-2h-.25v-1h-.25ZM3.5 18.75a.75.75 0 0 1 .75-.75h9.5a.75.75 0 0 1 0 1.5h-9.5a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" /> viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M7.25 2.75a.75.75 0 0 0-1.5 0C5.75 3.9 6.4 4.8 7 5.4c.35.35.5.7.5 1.1h-1a2.1 2.1 0 0 0-.8-1.6c-.45-.4-.95-1.1-.95-2.15ZM10.75 2.75a.75.75 0 0 0-1.5 0c0 1.15.65 2.05 1.25 2.65.35.35.5.7.5 1.1h-1a2.1 2.1 0 0 0-.8-1.6c-.45-.4-.95-1.1-.95-2.15Z"
/>
<path
fill-rule="evenodd"
d="M2.5 8.5A1.5 1.5 0 0 1 4 7h10a1.5 1.5 0 0 1 1.5 1.5v.5h.5a2.5 2.5 0 0 1 0 5h-.5v.5A3.5 3.5 0 0 1 12 18H6a3.5 3.5 0 0 1-3.5-3.5v-6Zm13 2v3h.5a1 1 0 1 0 0-2h-.25v-1h-.25ZM3.5 18.75a.75.75 0 0 1 .75-.75h9.5a.75.75 0 0 1 0 1.5h-9.5a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro arrow_up(class) %} {% macro arrow_up(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro arrow_down_tray(class) %} {% macro arrow_down_tray(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z" /> class="{{ class }}"
<path d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z" /> viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro arrow_up_tray(class) %} {% macro arrow_up_tray(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M9.25 13.25a.75.75 0 0 0 1.5 0V4.636l2.955 3.129a.75.75 0 0 0 1.09-1.03l-4.25-4.5a.75.75 0 0 0-1.09 0l-4.25 4.5a.75.75 0 1 0 1.09 1.03L9.25 4.636v8.614Z" /> class="{{ class }}"
<path d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z" /> viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M9.25 13.25a.75.75 0 0 0 1.5 0V4.636l2.955 3.129a.75.75 0 0 0 1.09-1.03l-4.25-4.5a.75.75 0 0 0-1.09 0l-4.25 4.5a.75.75 0 1 0 1.09 1.03L9.25 4.636v8.614Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro plus_circle(class) %} {% macro plus_circle(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-11.25a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-11.25a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro logout(class) %} {% macro logout(class) %}
<svg class="{{ class }}" viewBox="0 0 448 512" fill="currentColor" aria-hidden="true"> <svg
<path d="M320 48a48 48 0 1 0-96 0 48 48 0 1 0 96 0zM125.7 175.5c9.9-9.9 23.4-15.5 37.5-15.5 1.9 0 3.8.1 5.6.3L137.6 254c-9.3 28 1.7 58.8 26.8 74.5l86.2 53.9-25.4 88.8c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l28.7-100.4c5.9-20.6-2.6-42.6-20.7-53.9L238 299l30.9-82.4 5.1 12.3C289 264.7 323.9 288 362.7 288h21.3c17.7 0 32-14.3 32-32s-14.3-32-32-32h-21.3c-12.9 0-24.6-7.8-29.5-19.7l-6.3-15c-14.6-35.1-44.1-61.9-80.5-73.1l-48.7-15c-11.1-3.4-22.7-5.2-34.4-5.2-31 0-60.8 12.3-82.7 34.3L57.4 153.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l23.1-23.1zM91.2 352H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h69.6c19 0 36.2-11.2 43.9-28.5L157 361.6l-9.5-6c-17.5-10.9-30.5-26.8-37.9-44.9L91.2 352z"/> class="{{ class }}"
viewBox="0 0 448 512"
fill="currentColor"
aria-hidden="true"
>
<path
d="M320 48a48 48 0 1 0-96 0 48 48 0 1 0 96 0zM125.7 175.5c9.9-9.9 23.4-15.5 37.5-15.5 1.9 0 3.8.1 5.6.3L137.6 254c-9.3 28 1.7 58.8 26.8 74.5l86.2 53.9-25.4 88.8c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l28.7-100.4c5.9-20.6-2.6-42.6-20.7-53.9L238 299l30.9-82.4 5.1 12.3C289 264.7 323.9 288 362.7 288h21.3c17.7 0 32-14.3 32-32s-14.3-32-32-32h-21.3c-12.9 0-24.6-7.8-29.5-19.7l-6.3-15c-14.6-35.1-44.1-61.9-80.5-73.1l-48.7-15c-11.1-3.4-22.7-5.2-34.4-5.2-31 0-60.8 12.3-82.7 34.3L57.4 153.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l23.1-23.1zM91.2 352H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h69.6c19 0 36.2-11.2 43.9-28.5L157 361.6l-9.5-6c-17.5-10.9-30.5-26.8-37.9-44.9L91.2 352z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro beaker(class) %} {% macro beaker(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro coffee_bean(class) %} {% macro coffee_bean(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M9.75 2.5C6 3.3 3.5 6.3 3.5 10S6 16.7 9.75 17.5C9 16 8.25 13.5 8.25 10S9 4 9.75 2.5Z" /> class="{{ class }}"
<path d="M10.25 2.5C14 3.3 16.5 6.3 16.5 10S14 16.7 10.25 17.5C11 16 11.75 13.5 11.75 10S11 4 10.25 2.5Z" /> viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M9.75 2.5C6 3.3 3.5 6.3 3.5 10S6 16.7 9.75 17.5C9 16 8.25 13.5 8.25 10S9 4 9.75 2.5Z"
/>
<path
d="M10.25 2.5C14 3.3 16.5 6.3 16.5 10S14 16.7 10.25 17.5C11 16 11.75 13.5 11.75 10S11 4 10.25 2.5Z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro fire(class) %} {% macro fire(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M13.5 4.938a7 7 0 1 1-9.006 1.737c.202-.257.59-.218.793.039.278.352.594.672.943.954.332.269.786-.049.773-.476a5.977 5.977 0 0 1 .572-2.759 6.026 6.026 0 0 1 2.486-2.665c.247-.14.55-.016.677.238A6.967 6.967 0 0 0 13.5 4.938ZM14 12a4 4 0 0 1-4 4c-1.913 0-3.52-1.398-3.91-3.182-.093-.429.44-.643.814-.413a4.043 4.043 0 0 0 1.601.564c.303.038.531-.24.51-.544a5.975 5.975 0 0 1 1.315-4.192.447.447 0 0 1 .431-.16A4.001 4.001 0 0 1 14 12Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M13.5 4.938a7 7 0 1 1-9.006 1.737c.202-.257.59-.218.793.039.278.352.594.672.943.954.332.269.786-.049.773-.476a5.977 5.977 0 0 1 .572-2.759 6.026 6.026 0 0 1 2.486-2.665c.247-.14.55-.016.677.238A6.967 6.967 0 0 0 13.5 4.938ZM14 12a4 4 0 0 1-4 4c-1.913 0-3.52-1.398-3.91-3.182-.093-.429.44-.643.814-.413a4.043 4.043 0 0 0 1.601.564c.303.038.531-.24.51-.544a5.975 5.975 0 0 1 1.315-4.192.447.447 0 0 1 .431-.16A4.001 4.001 0 0 1 14 12Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro grinder(class) %} {% macro grinder(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" /> <path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" />
<rect x="5" y="16.5" width="10" height="1.5" rx=".5" /> <rect x="5" y="16.5" width="10" height="1.5" rx=".5" />
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro ellipsis_vertical(class) %} {% macro ellipsis_vertical(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM10 8.5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM11.5 15.5a1.5 1.5 0 10-3 0 1.5 1.5 0 003 0z" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM10 8.5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM11.5 15.5a1.5 1.5 0 10-3 0 1.5 1.5 0 003 0z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro x_circle(class) %} {% macro x_circle(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro chevron_down(class) %} {% macro chevron_down(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro chevron_up(class) %} {% macro chevron_up(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M14.78 11.78a.75.75 0 0 1-1.06 0L10 8.06l-3.72 3.72a.75.75 0 1 1-1.06-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M14.78 11.78a.75.75 0 0 1-1.06 0L10 8.06l-3.72 3.72a.75.75 0 1 1-1.06-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro user(class) %} {% macro user(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro sun(class) %} {% macro sun(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M10 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 2ZM10 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10ZM15.657 4.343a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 0 1-1.06-1.06l1.06-1.06a.75.75 0 0 1 1.06 0ZM18 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 18 10ZM15.657 15.657a.75.75 0 0 1-1.06 0l-1.06-1.06a.75.75 0 1 1 1.06-1.06l1.06 1.06a.75.75 0 0 1 0 1.06ZM10 18a.75.75 0 0 1-.75-.75v-1.5a.75.75 0 0 1 1.5 0v1.5A.75.75 0 0 1 10 18ZM4.343 15.657a.75.75 0 0 1 0-1.06l1.06-1.06a.75.75 0 1 1 1.061 1.06l-1.06 1.06a.75.75 0 0 1-1.061 0ZM2 10a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 2 10ZM4.343 4.343a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.06-1.06a.75.75 0 0 1 0-1.061Z" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M10 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 2ZM10 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10ZM15.657 4.343a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 0 1-1.06-1.06l1.06-1.06a.75.75 0 0 1 1.06 0ZM18 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 18 10ZM15.657 15.657a.75.75 0 0 1-1.06 0l-1.06-1.06a.75.75 0 1 1 1.06-1.06l1.06 1.06a.75.75 0 0 1 0 1.06ZM10 18a.75.75 0 0 1-.75-.75v-1.5a.75.75 0 0 1 1.5 0v1.5A.75.75 0 0 1 10 18ZM4.343 15.657a.75.75 0 0 1 0-1.06l1.06-1.06a.75.75 0 1 1 1.061 1.06l-1.06 1.06a.75.75 0 0 1-1.061 0ZM2 10a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 2 10ZM4.343 4.343a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.06-1.06a.75.75 0 0 1 0-1.061Z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro moon(class) %} {% macro moon(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M7.455 2.004a.75.75 0 0 1 .26.77 7 7 0 0 0 9.958 7.967.75.75 0 0 1 1.067.853A8.5 8.5 0 1 1 6.647 1.921a.75.75 0 0 1 .808.083Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M7.455 2.004a.75.75 0 0 1 .26.77 7 7 0 0 0 9.958 7.967.75.75 0 0 1 1.067.853A8.5 8.5 0 1 1 6.647 1.921a.75.75 0 0 1 .808.083Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro check_circle(class) %} {% macro check_circle(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro key(class) %} {% macro key(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro terminal(class) %} {% macro terminal(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M3.25 3A2.25 2.25 0 0 0 1 5.25v9.5A2.25 2.25 0 0 0 3.25 17h13.5A2.25 2.25 0 0 0 19 14.75v-9.5A2.25 2.25 0 0 0 16.75 3H3.25Zm.943 8.752a.75.75 0 0 1 .055-1.06L6.128 9l-1.88-1.693a.75.75 0 1 1 1.004-1.114l2.5 2.25a.75.75 0 0 1 0 1.114l-2.5 2.25a.75.75 0 0 1-1.06-.055ZM9.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M3.25 3A2.25 2.25 0 0 0 1 5.25v9.5A2.25 2.25 0 0 0 3.25 17h13.5A2.25 2.25 0 0 0 19 14.75v-9.5A2.25 2.25 0 0 0 16.75 3H3.25Zm.943 8.752a.75.75 0 0 1 .055-1.06L6.128 9l-1.88-1.693a.75.75 0 1 1 1.004-1.114l2.5 2.25a.75.75 0 0 1 0 1.114l-2.5 2.25a.75.75 0 0 1-1.06-.055ZM9.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro pencil(class) %} {% macro pencil(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.885L17.5 5.5a2.121 2.121 0 0 0-3-3L3.58 13.42a4 4 0 0 0-.885 1.343Z" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M2.695 14.763l-1.262 3.154a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.885L17.5 5.5a2.121 2.121 0 0 0-3-3L3.58 13.42a4 4 0 0 0-.885 1.343Z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro clipboard(class) %} {% macro clipboard(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M13.887 3.182c.396.037.79.08 1.183.128C16.194 3.45 17 4.414 17 5.517V16.75A2.25 2.25 0 0 1 14.75 19h-9.5A2.25 2.25 0 0 1 3 16.75V5.517c0-1.103.806-2.068 1.93-2.207.393-.048.787-.09 1.183-.128A3.001 3.001 0 0 1 9 1h2c1.373 0 2.531.923 2.887 2.182ZM7.5 4A1.5 1.5 0 0 1 9 2.5h2A1.5 1.5 0 0 1 12.5 4v.5h-5V4Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M13.887 3.182c.396.037.79.08 1.183.128C16.194 3.45 17 4.414 17 5.517V16.75A2.25 2.25 0 0 1 14.75 19h-9.5A2.25 2.25 0 0 1 3 16.75V5.517c0-1.103.806-2.068 1.93-2.207.393-.048.787-.09 1.183-.128A3.001 3.001 0 0 1 9 1h2c1.373 0 2.531.923 2.887 2.182ZM7.5 4A1.5 1.5 0 0 1 9 2.5h2A1.5 1.5 0 0 1 12.5 4v.5h-5V4Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro check(class) %} {% macro check(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro x_mark(class) %} {% macro x_mark(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro chevron_left(class) %} {% macro chevron_left(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro chevron_right(class) %} {% macro chevron_right(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /> class="{{ class }}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg> </svg>
{% endmacro %} {% endmacro %}

View file

@ -15,13 +15,22 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border bg-surface" <section
{% if bags.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(bags.next_page().unwrap())|safe }}" data-target="#bag-list"{% endif %} class="rounded-lg border bg-surface"
{% if bags.has_next() %}
data-infinite-scroll
data-next-url="{{ navigator.fragment_page_href(bags.next_page().unwrap())|safe }}"
data-target="#bag-list"
{% endif %}
> >
{{ table::search_header(navigator, "#bag-list") }} {{ table::search_header(navigator, "#bag-list") }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y text-left text-sm text-text"> <table
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary"> class="responsive-table min-w-full divide-y text-left text-sm text-text"
>
<thead
class="bg-surface-alt text-xs font-semibold text-text-secondary"
>
<tr> <tr>
{{ table::sortable_header("Added", "created-at", navigator, "#bag-list") }} {{ table::sortable_header("Added", "created-at", navigator, "#bag-list") }}
{{ table::sortable_header("Roaster", "roaster", navigator, "#bag-list") }} {{ table::sortable_header("Roaster", "roaster", navigator, "#bag-list") }}
@ -33,44 +42,81 @@
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
{% for bag in bags.items %} {% for bag in bags.items %}
<tr class="transition hover:bg-surface-alt" <tr
class="transition hover:bg-surface-alt"
onclick="window.location.href='/bags/{{ bag.id }}'" onclick="window.location.href='/bags/{{ bag.id }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td
data-label="Added"
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
>
<div>{{ bag.created_date }}</div> <div>{{ bag.created_date }}</div>
<div class="hidden md:block text-xs font-normal text-text-muted">{{ bag.created_time }}</div> <div
class="hidden md:block text-xs font-normal text-text-muted"
>
{{ bag.created_time }}
</div>
</td> </td>
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap font-medium text-text-secondary"> <td
data-label="Roaster"
class="px-4 py-3 whitespace-nowrap font-medium text-text-secondary"
>
{{ bag.roaster_name }} {{ bag.roaster_name }}
</td> </td>
<td data-label="Roast" class="card-title px-4 py-3 whitespace-nowrap"> <td
data-label="Roast"
class="card-title px-4 py-3 whitespace-nowrap"
>
{{ bag.roast_name }} {{ bag.roast_name }}
</td> </td>
<td data-label="Status" class="mobile-hidden px-4 py-3 whitespace-nowrap"> <td
data-label="Status"
class="mobile-hidden px-4 py-3 whitespace-nowrap"
>
{% if bag.closed %} {% if bag.closed %}
<span class="text-text-muted">Closed</span> <span class="text-text-muted">Closed</span>
{% else %} {% else %}
<div> <div>
<div class="h-2.5 rounded-full bg-surface-alt overflow-hidden"> <div
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div> class="h-2.5 rounded-full bg-surface-alt overflow-hidden"
>
<div
class="h-full rounded-full bg-accent"
style="width: {{ bag.used_percent }}%"
></div>
</div>
<div class="mt-0.5 text-xs text-text-muted">
{{ bag.remaining }} / {{ bag.amount }}
</div> </div>
<div class="mt-0.5 text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}</div>
</div> </div>
{% endif %} {% endif %}
</td> </td>
{% if !bag.closed %} {% if !bag.closed %}
<td data-label="" class="card-progress md:hidden"> <td data-label="" class="card-progress md:hidden">
<div class="h-1.5 rounded-full bg-surface-alt overflow-hidden"> <div
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div> class="h-1.5 rounded-full bg-surface-alt overflow-hidden"
>
<div
class="h-full rounded-full bg-accent"
style="width: {{ bag.used_percent }}%"
></div>
</div>
<div class="mt-0.5 text-right text-xs text-text-muted">
{{ bag.remaining }} / {{ bag.amount }}
</div> </div>
<div class="mt-0.5 text-right text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}</div>
</td> </td>
{% endif %} {% endif %}
<td data-label="Finished" class="mobile-hidden whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td
data-label="Finished"
class="mobile-hidden whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
>
<div>{{ bag.finished_date }}</div> <div>{{ bag.finished_date }}</div>
</td> </td>
{% if bag.closed %} {% if bag.closed %}
<td data-label="Finished" class="whitespace-nowrap px-4 py-3 font-medium text-text-secondary md:hidden"> <td
data-label="Finished"
class="whitespace-nowrap px-4 py-3 font-medium text-text-secondary md:hidden"
>
<div>{{ bag.finished_date }}</div> <div>{{ bag.finished_date }}</div>
</td> </td>
{% endif %} {% endif %}
@ -81,8 +127,10 @@
<span class="text-text-muted">Closed</span> <span class="text-text-muted">Closed</span>
{% endif %} {% endif %}
</span> </span>
<a href="/bags/{{ bag.id }}" <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"> href="/bags/{{ bag.id }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</a> </a>
</div> </div>
@ -93,11 +141,16 @@
</table> </table>
</div> </div>
{% if bags.items.is_empty() %} {% if bags.items.is_empty() %}
<div class="p-8 text-center text-text-muted">No bags match the search.</div> <div class="p-8 text-center text-text-muted">
No bags match the search.
</div>
{% endif %} {% endif %}
{{ table::pagination_header(bags, navigator, "#bag-list") }} {{ table::pagination_header(bags, navigator, "#bag-list") }}
{% if bags.has_next() %} {% if bags.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> <div
class="infinite-scroll-sentinel h-4 md:hidden"
aria-hidden="true"
></div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}

View file

@ -15,14 +15,23 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border bg-surface" <section
{% if brews.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(brews.next_page().unwrap())|safe }}" data-target="#brew-list"{% endif %} class="rounded-lg border bg-surface"
{% if brews.has_next() %}
data-infinite-scroll
data-next-url="{{ navigator.fragment_page_href(brews.next_page().unwrap())|safe }}"
data-target="#brew-list"
{% endif %}
> >
{{ table::search_header(navigator, "#brew-list") }} {{ table::search_header(navigator, "#brew-list") }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y text-left text-sm text-text"> <table
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary"> class="responsive-table min-w-full divide-y text-left text-sm text-text"
>
<thead
class="bg-surface-alt text-xs font-semibold text-text-secondary"
>
<tr> <tr>
{{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }} {{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }}
<th scope="col" class="px-4 py-3">Coffee</th> <th scope="col" class="px-4 py-3">Coffee</th>
@ -35,39 +44,83 @@
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
{% for brew in brews.items %} {% for brew in brews.items %}
<tr class="transition hover:bg-surface-alt" <tr
class="transition hover:bg-surface-alt"
onclick="window.location.href='/brews/{{ brew.id }}'" onclick="window.location.href='/brews/{{ brew.id }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td
data-label="Added"
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
>
<div>{{ brew.created_date }}</div> <div>{{ brew.created_date }}</div>
<div class="hidden md:block text-xs font-normal text-text-muted">{{ brew.created_time }}</div> <div
class="hidden md:block text-xs font-normal text-text-muted"
>
{{ brew.created_time }}
</div>
</td> </td>
<td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap"> <td
data-label="Coffee"
class="card-title px-4 py-3 whitespace-nowrap"
>
<div class="font-medium text-text">{{ brew.roast_name }}</div> <div class="font-medium text-text">{{ brew.roast_name }}</div>
<div class="hidden md:block text-xs text-text-muted">{{ brew.roaster_name }}</div> <div class="hidden md:block text-xs text-text-muted">
{{ brew.roaster_name }}
</div>
</td>
<td
data-label="Roaster"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ brew.roaster_name }}
</td> </td>
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.roaster_name }}</td>
<td data-label="Grind" class="px-4 py-3 whitespace-nowrap"> <td data-label="Grind" class="px-4 py-3 whitespace-nowrap">
<div>{{ brew.grind_setting }}</div> <div>{{ brew.grind_setting }}</div>
<div class="hidden md:block text-xs text-text-muted">{{ brew.grinder_name }}</div> <div class="hidden md:block text-xs text-text-muted">
{{ brew.grinder_name }}
</div>
</td>
<td
data-label="Grinder"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ brew.grinder_name }}
</td> </td>
<td data-label="Grinder" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.grinder_name }}</td>
<td data-label="Recipe" class="px-4 py-3 whitespace-nowrap"> <td data-label="Recipe" class="px-4 py-3 whitespace-nowrap">
<div>{{ brew.coffee_weight }} · {{ brew.water_volume }}</div> <div>{{ brew.coffee_weight }} · {{ brew.water_volume }}</div>
<div class="hidden md:block text-xs text-text-muted">{% if let Some(bt) = brew.brew_time %}{{ bt }} · {% endif %}{{ brew.water_temp }}</div> <div class="hidden md:block text-xs text-text-muted">
{% if let Some(bt) = brew.brew_time %}{{ bt }} ·{% endif %}{{ brew.water_temp }}
</div>
</td> </td>
{% if let Some(bt) = brew.brew_time %} {% if let Some(bt) = brew.brew_time %}
<td data-label="Brew Time" class="px-4 py-3 whitespace-nowrap md:hidden">{{ bt }}</td> <td
data-label="Brew Time"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ bt }}
</td>
{% endif %} {% endif %}
<td data-label="Temp" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.water_temp }}</td> <td
data-label="Temp"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ brew.water_temp }}
</td>
<td data-label="Brewer" class="px-4 py-3 whitespace-nowrap"> <td data-label="Brewer" class="px-4 py-3 whitespace-nowrap">
<div>{{ brew.brewer_name }}</div> <div>{{ brew.brewer_name }}</div>
{% if let Some(fp_name) = brew.filter_paper_name %} {% if let Some(fp_name) = brew.filter_paper_name %}
<div class="hidden md:block text-xs text-text-muted">{{ fp_name }}</div> <div class="hidden md:block text-xs text-text-muted">
{{ fp_name }}
</div>
{% endif %} {% endif %}
</td> </td>
{% if let Some(fp_name) = brew.filter_paper_name %} {% if let Some(fp_name) = brew.filter_paper_name %}
<td data-label="Filter" class="px-4 py-3 whitespace-nowrap md:hidden">{{ fp_name }}</td> <td
data-label="Filter"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ fp_name }}
</td>
{% endif %} {% endif %}
<td data-label="Notes" class="px-4 py-3"> <td data-label="Notes" class="px-4 py-3">
{% if !brew.quick_notes.is_empty() %} {% if !brew.quick_notes.is_empty() %}
@ -81,8 +134,10 @@
{% endif %} {% endif %}
</td> </td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<a href="/brews/{{ brew.id }}" <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"> href="/brews/{{ brew.id }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</a> </a>
</td> </td>
@ -101,7 +156,10 @@
{{ table::pagination_header(brews, navigator, "#brew-list") }} {{ table::pagination_header(brews, navigator, "#brew-list") }}
{% if brews.has_next() %} {% if brews.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> <div
class="infinite-scroll-sentinel h-4 md:hidden"
aria-hidden="true"
></div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}

View file

@ -15,13 +15,22 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border bg-surface" <section
{% if cafes.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(cafes.next_page().unwrap())|safe }}" data-target="#cafe-list"{% endif %} class="rounded-lg border bg-surface"
{% if cafes.has_next() %}
data-infinite-scroll
data-next-url="{{ navigator.fragment_page_href(cafes.next_page().unwrap())|safe }}"
data-target="#cafe-list"
{% endif %}
> >
{{ table::search_header(navigator, "#cafe-list") }} {{ table::search_header(navigator, "#cafe-list") }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y text-left text-sm text-text"> <table
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary"> class="responsive-table min-w-full divide-y text-left text-sm text-text"
>
<thead
class="bg-surface-alt text-xs font-semibold text-text-secondary"
>
<tr> <tr>
{{ table::sortable_header("Added", "created-at", navigator, "#cafe-list") }} {{ table::sortable_header("Added", "created-at", navigator, "#cafe-list") }}
{{ table::sortable_header("Name", "name", navigator, "#cafe-list") }} {{ table::sortable_header("Name", "name", navigator, "#cafe-list") }}
@ -41,20 +50,52 @@
class="transition hover:bg-surface-alt" class="transition hover:bg-surface-alt"
onclick="window.location.href='{{ cafe.detail_path }}'" onclick="window.location.href='{{ cafe.detail_path }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td
data-label="Added"
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
>
<div>{{ cafe.created_date }}</div> <div>{{ cafe.created_date }}</div>
<div class="hidden md:block text-xs font-normal text-text-muted">{{ cafe.created_time }}</div> <div
class="hidden md:block text-xs font-normal text-text-muted"
>
{{ cafe.created_time }}
</div>
</td> </td>
<td data-label="Name" class="card-title px-4 py-3 font-medium text-text"> <td
data-label="Name"
class="card-title px-4 py-3 font-medium text-text"
>
{{ cafe.name }} {{ cafe.name }}
</td> </td>
<td data-label="Location" class="px-4 py-3 whitespace-nowrap md:hidden">{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }}</td> <td
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cafe.city }}</td> data-label="Location"
<td data-label="Country" class="mobile-hidden px-4 py-3 whitespace-nowrap">{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }}</td> class="px-4 py-3 whitespace-nowrap md:hidden"
<td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cafe.city }}</td> >
{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }}
</td>
<td
data-label="City"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ cafe.city }}
</td>
<td
data-label="Country"
class="mobile-hidden px-4 py-3 whitespace-nowrap"
>
{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }}
</td>
<td
data-label="City"
class="mobile-hidden px-4 py-3 whitespace-nowrap"
>
{{ cafe.city }}
</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<a href="{{ cafe.detail_path }}" <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"> href="{{ cafe.detail_path }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</a> </a>
</td> </td>
@ -64,11 +105,16 @@
</table> </table>
</div> </div>
{% if cafes.items.is_empty() %} {% if cafes.items.is_empty() %}
<div class="p-8 text-center text-text-muted">No cafes match the search.</div> <div class="p-8 text-center text-text-muted">
No cafes match the search.
</div>
{% endif %} {% endif %}
{{ table::pagination_header(cafes, navigator, "#cafe-list") }} {{ table::pagination_header(cafes, navigator, "#cafe-list") }}
{% if cafes.has_next() %} {% if cafes.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> <div
class="infinite-scroll-sentinel h-4 md:hidden"
aria-hidden="true"
></div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}

View file

@ -15,13 +15,22 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border bg-surface" <section
{% if cups.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(cups.next_page().unwrap())|safe }}" data-target="#cup-list"{% endif %} class="rounded-lg border bg-surface"
{% if cups.has_next() %}
data-infinite-scroll
data-next-url="{{ navigator.fragment_page_href(cups.next_page().unwrap())|safe }}"
data-target="#cup-list"
{% endif %}
> >
{{ table::search_header(navigator, "#cup-list") }} {{ table::search_header(navigator, "#cup-list") }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y text-left text-sm text-text"> <table
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary"> class="responsive-table min-w-full divide-y text-left text-sm text-text"
>
<thead
class="bg-surface-alt text-xs font-semibold text-text-secondary"
>
<tr> <tr>
{{ table::sortable_header("Added", "created-at", navigator, "#cup-list") }} {{ table::sortable_header("Added", "created-at", navigator, "#cup-list") }}
{{ table::sortable_header("Roast", "roast", navigator, "#cup-list") }} {{ table::sortable_header("Roast", "roast", navigator, "#cup-list") }}
@ -38,24 +47,61 @@
class="transition hover:bg-surface-alt" class="transition hover:bg-surface-alt"
onclick="window.location.href='/cups/{{ cup.id }}'" onclick="window.location.href='/cups/{{ cup.id }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td
data-label="Added"
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
>
<div>{{ cup.created_date }}</div> <div>{{ cup.created_date }}</div>
<div class="hidden md:block text-xs font-normal text-text-muted">{{ cup.created_time }}</div> <div
class="hidden md:block text-xs font-normal text-text-muted"
>
{{ cup.created_time }}
</div>
</td> </td>
<td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap md:hidden"> <td
data-label="Coffee"
class="card-title px-4 py-3 whitespace-nowrap md:hidden"
>
<div class="font-medium text-text">{{ cup.roast_name }}</div> <div class="font-medium text-text">{{ cup.roast_name }}</div>
</td> </td>
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cup.roaster_name }}</td> <td
<td data-label="Roast" class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-text">{{ cup.roast_name }}</td> data-label="Roaster"
<td data-label="Roaster" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cup.roaster_name }}</td> class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ cup.roaster_name }}
</td>
<td
data-label="Roast"
class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-text"
>
{{ cup.roast_name }}
</td>
<td
data-label="Roaster"
class="mobile-hidden px-4 py-3 whitespace-nowrap"
>
{{ cup.roaster_name }}
</td>
<td data-label="Cafe" class="px-4 py-3 whitespace-nowrap"> <td data-label="Cafe" class="px-4 py-3 whitespace-nowrap">
{{ cup.cafe_name }} {{ cup.cafe_name }}
</td> </td>
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cup.cafe_city }}</td> <td
<td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cup.cafe_city }}</td> data-label="City"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ cup.cafe_city }}
</td>
<td
data-label="City"
class="mobile-hidden px-4 py-3 whitespace-nowrap"
>
{{ cup.cafe_city }}
</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<a href="/cups/{{ cup.id }}" <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"> href="/cups/{{ cup.id }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</a> </a>
</td> </td>
@ -65,11 +111,16 @@
</table> </table>
</div> </div>
{% if cups.items.is_empty() %} {% if cups.items.is_empty() %}
<div class="p-8 text-center text-text-muted">No cups match the search.</div> <div class="p-8 text-center text-text-muted">
No cups match the search.
</div>
{% endif %} {% endif %}
{{ table::pagination_header(cups, navigator, "#cup-list") }} {{ table::pagination_header(cups, navigator, "#cup-list") }}
{% if cups.has_next() %} {% if cups.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> <div
class="infinite-scroll-sentinel h-4 md:hidden"
aria-hidden="true"
></div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}

View file

@ -15,14 +15,23 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border bg-surface" <section
{% if gear.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(gear.next_page().unwrap())|safe }}" data-target="#gear-list"{% endif %} class="rounded-lg border bg-surface"
{% if gear.has_next() %}
data-infinite-scroll
data-next-url="{{ navigator.fragment_page_href(gear.next_page().unwrap())|safe }}"
data-target="#gear-list"
{% endif %}
> >
{{ table::search_header(navigator, "#gear-list") }} {{ table::search_header(navigator, "#gear-list") }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y text-left text-sm text-text"> <table
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary"> class="responsive-table min-w-full divide-y text-left text-sm text-text"
>
<thead
class="bg-surface-alt text-xs font-semibold text-text-secondary"
>
<tr> <tr>
{{ table::sortable_header("Added", "created-at", navigator, "#gear-list") }} {{ table::sortable_header("Added", "created-at", navigator, "#gear-list") }}
{{ table::sortable_header("Category", "category", navigator, "#gear-list") }} {{ table::sortable_header("Category", "category", navigator, "#gear-list") }}
@ -33,26 +42,47 @@
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
{% for item in gear.items %} {% for item in gear.items %}
<tr class="transition hover:bg-surface-alt" <tr
class="transition hover:bg-surface-alt"
onclick="window.location.href='/gear/{{ item.id }}'" onclick="window.location.href='/gear/{{ item.id }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td
data-label="Added"
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
>
<div>{{ item.created_date }}</div> <div>{{ item.created_date }}</div>
<div class="hidden md:block text-xs font-normal text-text-muted">{{ item.created_time }}</div> <div
class="hidden md:block text-xs font-normal text-text-muted"
>
{{ item.created_time }}
</div>
</td>
<td
data-label=""
class="card-title px-4 py-3 whitespace-nowrap md:hidden"
>
{{ item.make }} {{ item.model }}
</td> </td>
<td data-label="" class="card-title px-4 py-3 whitespace-nowrap md:hidden">{{ item.make }} {{ item.model }}</td>
<td data-label="Category" class="px-4 py-3 whitespace-nowrap"> <td data-label="Category" class="px-4 py-3 whitespace-nowrap">
{{ item.category_label }} {{ item.category_label }}
</td> </td>
<td data-label="Make" class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-text-secondary"> <td
data-label="Make"
class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-text-secondary"
>
{{ item.make }} {{ item.make }}
</td> </td>
<td data-label="Model" class="mobile-hidden px-4 py-3 whitespace-nowrap"> <td
data-label="Model"
class="mobile-hidden px-4 py-3 whitespace-nowrap"
>
{{ item.model }} {{ item.model }}
</td> </td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<a href="/gear/{{ item.id }}" <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"> href="/gear/{{ item.id }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</a> </a>
</td> </td>
@ -62,11 +92,16 @@
</table> </table>
</div> </div>
{% if gear.items.is_empty() %} {% if gear.items.is_empty() %}
<div class="p-8 text-center text-text-muted">No gear matches the search.</div> <div class="p-8 text-center text-text-muted">
No gear matches the search.
</div>
{% endif %} {% endif %}
{{ table::pagination_header(gear, navigator, "#gear-list") }} {{ table::pagination_header(gear, navigator, "#gear-list") }}
{% if gear.has_next() %} {% if gear.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> <div
class="infinite-scroll-sentinel h-4 md:hidden"
aria-hidden="true"
></div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}

View file

@ -15,13 +15,22 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border bg-surface" <section
{% if roasts.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(roasts.next_page().unwrap())|safe }}" data-target="#roast-list"{% endif %} class="rounded-lg border bg-surface"
{% if roasts.has_next() %}
data-infinite-scroll
data-next-url="{{ navigator.fragment_page_href(roasts.next_page().unwrap())|safe }}"
data-target="#roast-list"
{% endif %}
> >
{{ table::search_header(navigator, "#roast-list") }} {{ table::search_header(navigator, "#roast-list") }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y text-left text-sm text-text"> <table
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary"> class="responsive-table min-w-full divide-y text-left text-sm text-text"
>
<thead
class="bg-surface-alt text-xs font-semibold text-text-secondary"
>
<tr> <tr>
{{ table::sortable_header("Added", "created-at", navigator, "#roast-list") }} {{ table::sortable_header("Added", "created-at", navigator, "#roast-list") }}
{{ table::sortable_header("Roast", "name", navigator, "#roast-list") }} {{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
@ -42,29 +51,54 @@
class="transition hover:bg-surface-alt" class="transition hover:bg-surface-alt"
onclick="window.location.href='{{ roast.detail_path }}'" onclick="window.location.href='{{ roast.detail_path }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td
data-label="Added"
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
>
<div>{{ roast.created_date }}</div> <div>{{ roast.created_date }}</div>
<div class="hidden md:block text-xs font-normal text-text-muted">{{ roast.created_time }}</div> <div
class="hidden md:block text-xs font-normal text-text-muted"
>
{{ roast.created_time }}
</div>
</td> </td>
<td data-label="Roast" class="card-title px-4 py-3"> <td data-label="Roast" class="card-title px-4 py-3">
<div class="font-medium text-text">{{ roast.name }}</div> <div class="font-medium text-text">{{ roast.name }}</div>
<div class="hidden md:block text-xs text-text-muted">{{ roast.roaster_label }}</div> <div class="hidden md:block text-xs text-text-muted">
{{ roast.roaster_label }}
</div>
</td>
<td
data-label="Roaster"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ roast.roaster_label }}
</td> </td>
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roast.roaster_label }}</td>
<td data-label="Origin" class="px-4 py-3 whitespace-nowrap"> <td data-label="Origin" class="px-4 py-3 whitespace-nowrap">
{% if !roast.origin_flag.is_empty() %}{{ roast.origin_flag }}{% endif %}{{ roast.origin }} {% if !roast.origin_flag.is_empty() %}{{ roast.origin_flag }}{% endif %}{{ roast.origin }}
{% if !roast.producer.is_empty() %} {% if !roast.producer.is_empty() %}
<div class="hidden md:block text-xs text-text-muted">{{ roast.producer }}</div> <div class="hidden md:block text-xs text-text-muted">
{{ roast.producer }}
</div>
{% endif %} {% endif %}
</td> </td>
{% if !roast.producer.is_empty() %} {% if !roast.producer.is_empty() %}
<td data-label="Producer" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roast.producer }}</td> <td
data-label="Producer"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ roast.producer }}
</td>
{% endif %} {% endif %}
<td data-label="Tasting Notes" class="px-4 py-3"> <td data-label="Tasting Notes" class="px-4 py-3">
{% if !roast.tasting_notes.is_empty() %} {% if !roast.tasting_notes.is_empty() %}
<div class="flex flex-wrap gap-1 md:justify-start justify-end"> <div
class="flex flex-wrap gap-1 md:justify-start justify-end"
>
{% for note in roast.tasting_notes %} {% for note in roast.tasting_notes %}
<span class="{{ note.pill_class }}">{{ note.label }}</span> <span class="{{ note.pill_class }}"
>{{ note.label }}</span
>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@ -72,8 +106,10 @@
{% endif %} {% endif %}
</td> </td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<a href="{{ roast.detail_path }}" <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"> href="{{ roast.detail_path }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</a> </a>
</td> </td>
@ -83,11 +119,16 @@
</table> </table>
</div> </div>
{% if roasts.items.is_empty() %} {% if roasts.items.is_empty() %}
<div class="p-8 text-center text-text-muted">No roasts match the search.</div> <div class="p-8 text-center text-text-muted">
No roasts match the search.
</div>
{% endif %} {% endif %}
{{ table::pagination_header(roasts, navigator, "#roast-list") }} {{ table::pagination_header(roasts, navigator, "#roast-list") }}
{% if roasts.has_next() %} {% if roasts.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> <div
class="infinite-scroll-sentinel h-4 md:hidden"
aria-hidden="true"
></div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}

View file

@ -15,13 +15,22 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border bg-surface" <section
{% if roasters.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(roasters.next_page().unwrap())|safe }}" data-target="#roaster-list"{% endif %} class="rounded-lg border bg-surface"
{% if roasters.has_next() %}
data-infinite-scroll
data-next-url="{{ navigator.fragment_page_href(roasters.next_page().unwrap())|safe }}"
data-target="#roaster-list"
{% endif %}
> >
{{ table::search_header(navigator, "#roaster-list") }} {{ table::search_header(navigator, "#roaster-list") }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y text-left text-sm text-text"> <table
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary"> class="responsive-table min-w-full divide-y text-left text-sm text-text"
>
<thead
class="bg-surface-alt text-xs font-semibold text-text-secondary"
>
<tr> <tr>
{{ table::sortable_header("Added", "created-at", navigator, "#roaster-list") }} {{ table::sortable_header("Added", "created-at", navigator, "#roaster-list") }}
{{ table::sortable_header("Name", "name", navigator, "#roaster-list") }} {{ table::sortable_header("Name", "name", navigator, "#roaster-list") }}
@ -41,22 +50,54 @@
class="transition hover:bg-surface-alt" class="transition hover:bg-surface-alt"
onclick="window.location.href='{{ roaster.detail_path }}'" onclick="window.location.href='{{ roaster.detail_path }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td
data-label="Added"
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
>
<div>{{ roaster.created_date }}</div> <div>{{ roaster.created_date }}</div>
<div class="hidden md:block text-xs font-normal text-text-muted">{{ roaster.created_time }}</div> <div
class="hidden md:block text-xs font-normal text-text-muted"
>
{{ roaster.created_time }}
</div>
</td> </td>
<td data-label="Name" class="card-title px-4 py-3 font-medium text-text"> <td
data-label="Name"
class="card-title px-4 py-3 font-medium text-text"
>
{{ roaster.name }} {{ roaster.name }}
</td> </td>
<td data-label="Location" class="px-4 py-3 whitespace-nowrap md:hidden">{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}</td> <td
data-label="Location"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }}
</td>
{% if !roaster.city.is_empty() %} {% if !roaster.city.is_empty() %}
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roaster.city }}</td> <td
data-label="City"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{{ roaster.city }}
</td>
{% endif %} {% endif %}
<td data-label="Country" class="mobile-hidden px-4 py-3 whitespace-nowrap">{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}</td> <td
<td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ roaster.city }}</td> data-label="Country"
class="mobile-hidden px-4 py-3 whitespace-nowrap"
>
{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }}
</td>
<td
data-label="City"
class="mobile-hidden px-4 py-3 whitespace-nowrap"
>
{{ roaster.city }}
</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<a href="{{ roaster.detail_path }}" <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"> href="{{ roaster.detail_path }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
>
{{ icons::chevron_right("h-5 w-5") }} {{ icons::chevron_right("h-5 w-5") }}
</a> </a>
</td> </td>
@ -66,11 +107,16 @@
</table> </table>
</div> </div>
{% if roasters.items.is_empty() %} {% if roasters.items.is_empty() %}
<div class="p-8 text-center text-text-muted">No roasters match the search.</div> <div class="p-8 text-center text-text-muted">
No roasters match the search.
</div>
{% endif %} {% endif %}
{{ table::pagination_header(roasters, navigator, "#roaster-list") }} {{ table::pagination_header(roasters, navigator, "#roaster-list") }}
{% if roasters.has_next() %} {% if roasters.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> <div
class="infinite-scroll-sentinel h-4 md:hidden"
aria-hidden="true"
></div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}

View file

@ -7,7 +7,9 @@
class="input-field w-full text-sm" class="input-field w-full text-sm"
data-search-url="{{ navigator.search_href_prefix()|safe }}" data-search-url="{{ navigator.search_href_prefix()|safe }}"
data-on:input__debounce.300ms="history.pushState(null, '', el.dataset.searchUrl + el.value); @get(el.dataset.searchUrl + el.value, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})" data-on:input__debounce.300ms="history.pushState(null, '', el.dataset.searchUrl + el.value); @get(el.dataset.searchUrl + el.value, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
{% if navigator.has_search() %}data-init="el.focus(); el.selectionStart = el.value.length"{% endif %} {% if navigator.has_search() %}
data-init="el.focus(); el.selectionStart = el.value.length"
{% endif %}
/> />
</div> </div>
{% endmacro %} {% endmacro %}
@ -17,8 +19,8 @@
class="pagination-controls hidden md:flex flex-wrap items-center justify-between gap-x-3 border-t px-4 py-1.5 text-xs leading-none text-text-secondary" class="pagination-controls hidden md:flex flex-wrap items-center justify-between gap-x-3 border-t px-4 py-1.5 text-xs leading-none text-text-secondary"
> >
<span> <span>
Showing {{ items.start_index() }}&ndash;{{ items.end_index() }} of {{ items.total }} Showing {{ items.start_index() }}&ndash;{{ items.end_index() }} of
results {{ items.total }} results
</span> </span>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -26,7 +28,11 @@
type="button" type="button"
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40" class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
{% if items.has_previous() %} {% if items.has_previous() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.previous_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.previous_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})" data-on:click="history.pushState(null, '',
'{{ navigator.page_href(items.previous_page().unwrap())|safe }}');
@get('{{ navigator.fragment_page_href(items.previous_page().unwrap())|safe }}',
{responseOverrides: {selector: '{{ target_selector }}', mode:
'replace'}})"
{% else %} {% else %}
disabled disabled
{% endif %} {% endif %}
@ -45,7 +51,11 @@
type="button" type="button"
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40" class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
{% if items.has_next() %} {% if items.has_next() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.next_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.next_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})" data-on:click="history.pushState(null, '',
'{{ navigator.page_href(items.next_page().unwrap())|safe }}');
@get('{{ navigator.fragment_page_href(items.next_page().unwrap())|safe }}',
{responseOverrides: {selector: '{{ target_selector }}', mode:
'replace'}})"
{% else %} {% else %}
disabled disabled
{% endif %} {% endif %}
@ -54,17 +64,54 @@
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</button> </button>
</div> </div>
<label class="flex items-center gap-2 text-xs font-semibold text-text-secondary"> <label
class="flex items-center gap-2 text-xs font-semibold text-text-secondary"
>
<span>Rows</span> <span>Rows</span>
<select <select
class="rounded-md border bg-surface px-2 py-1 text-xs font-semibold text-accent transition hover:border-accent" class="rounded-md border bg-surface px-2 py-1 text-xs font-semibold text-accent transition hover:border-accent"
data-on:change="history.pushState(null, '', evt.target.selectedOptions[0].dataset.url); @get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})" data-on:change="history.pushState(null, '', evt.target.selectedOptions[0].dataset.url); @get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
> >
<option value="5" data-href="{{ navigator.fragment_rows_href("5")|safe }}" data-url="{{ navigator.rows_href("5")|safe }}" {% if items.is_page_size(5) %}selected{% endif %}>5</option> <option
<option value="10" data-href="{{ navigator.fragment_rows_href("10")|safe }}" data-url="{{ navigator.rows_href("10")|safe }}" {% if items.is_page_size(10) %}selected{% endif %}>10</option> value="5"
<option value="20" data-href="{{ navigator.fragment_rows_href("20")|safe }}" data-url="{{ navigator.rows_href("20")|safe }}" {% if items.is_page_size(20) %}selected{% endif %}>20</option> data-href="{{ navigator.fragment_rows_href("5")|safe }}"
<option value="50" data-href="{{ navigator.fragment_rows_href("50")|safe }}" data-url="{{ navigator.rows_href("50")|safe }}" {% if items.is_page_size(50) %}selected{% endif %}>50</option> data-url="{{ navigator.rows_href("5")|safe }}"
<option value="all" data-href="{{ navigator.fragment_rows_href("all")|safe }}" data-url="{{ navigator.rows_href("all")|safe }}" {% if items.is_showing_all() %}selected{% endif %}>All</option> {% if items.is_page_size(5) %}selected{% endif %}
>
5
</option>
<option
value="10"
data-href="{{ navigator.fragment_rows_href("10")|safe }}"
data-url="{{ navigator.rows_href("10")|safe }}"
{% if items.is_page_size(10) %}selected{% endif %}
>
10
</option>
<option
value="20"
data-href="{{ navigator.fragment_rows_href("20")|safe }}"
data-url="{{ navigator.rows_href("20")|safe }}"
{% if items.is_page_size(20) %}selected{% endif %}
>
20
</option>
<option
value="50"
data-href="{{ navigator.fragment_rows_href("50")|safe }}"
data-url="{{ navigator.rows_href("50")|safe }}"
{% if items.is_page_size(50) %}selected{% endif %}
>
50
</option>
<option
value="all"
data-href="{{ navigator.fragment_rows_href("all")|safe }}"
data-url="{{ navigator.rows_href("all")|safe }}"
{% if items.is_showing_all() %}selected{% endif %}
>
All
</option>
</select> </select>
</label> </label>
</div> </div>

View file

@ -1,42 +1,29 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% macro location_search_js() %} {% macro location_search_js() %}
const locateUser = (triggerEl) => { const locateUser = (triggerEl) => { const root =
const root = triggerEl.closest('[data-location-root]'); triggerEl.closest('[data-location-root]'); const emit = (name, detail) =>
const emit = (name, detail) => root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); if
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); (!navigator.geolocation) { emit('location-error', { message: 'Geolocation not
supported by this browser.' }); return; } emit('location-start');
if (!navigator.geolocation) { navigator.geolocation.getCurrentPosition( (pos) => { emit('location-found', {
emit('location-error', { message: 'Geolocation not supported by this browser.' }); lat: pos.coords.latitude, lng: pos.coords.longitude }); }, (err) => { if
return; (err.code === 1) { emit('location-error', { message: 'Location access denied.
} Search by name instead.' }); } else { emit('location-error', { message: 'Could
not determine location. Search by name instead.' }); } }, {
emit('location-start'); enableHighAccuracy: true, timeout: 15000 }, ); };
navigator.geolocation.getCurrentPosition(
(pos) => {
emit('location-found', { lat: pos.coords.latitude, lng: pos.coords.longitude });
},
(err) => {
if (err.code === 1) {
emit('location-error', { message: 'Location access denied. Search by name instead.' });
} else {
emit('location-error', { message: 'Could not determine location. Search by name instead.' });
}
},
{ enableHighAccuracy: true, timeout: 15000 },
);
};
{% endmacro %} {% endmacro %}
{% macro location_search(error_signal) %} {% macro location_search(error_signal) %}
<div data-location-root <div
data-location-root
data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; @get('/api/v1/nearby-cafes?lat=' + evt.detail.lat + '&lng=' + evt.detail.lng + '&q=coffee', {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})" data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; @get('/api/v1/nearby-cafes?lat=' + evt.detail.lat + '&lng=' + evt.detail.lng + '&q=coffee', {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
data-on:location-error="$_locating = false; {{ error_signal }} = evt.detail.message" data-on:location-error="$_locating = false; {{ error_signal }} = evt.detail.message"
data-on:location-start="$_locating = true" data-on:location-start="$_locating = true"
> >
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<button type="button" <button
type="button"
data-on:click="locateUser(el)" data-on:click="locateUser(el)"
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 transition hover:bg-surface-alt" class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 transition hover:bg-surface-alt"
data-class="{'text-accent': $_locationFound, 'text-text': !$_locationFound}" data-class="{'text-accent': $_locationFound, 'text-text': !$_locationFound}"
@ -44,20 +31,29 @@ const locateUser = (triggerEl) => {
aria-label="Use my location" aria-label="Use my location"
> >
<span data-show="!$_locating">{{ icons::location("h-5 w-5") }}</span> <span data-show="!$_locating">{{ icons::location("h-5 w-5") }}</span>
<span data-show="$_locating" style="display: none">{{ icons::spinner("h-5 w-5") }}</span> <span data-show="$_locating" style="display: none"
>{{ icons::spinner("h-5 w-5") }}</span
>
</button> </button>
<input type="text" data-bind:_city-name <input
type="text"
data-bind:_city-name
class="input-field w-full" class="input-field w-full"
data-attr:placeholder="$_locationFound ? 'Using current location (' + $_userLat.toFixed(2) + ', ' + $_userLng.toFixed(2) + ')' : 'Name of city or area'" data-attr:placeholder="$_locationFound ? 'Using current location (' + $_userLat.toFixed(2) + ', ' + $_userLng.toFixed(2) + ')' : 'Name of city or area'"
data-attr:disabled="$_locationFound" data-attr:disabled="$_locationFound"
data-on:input__debounce.350ms="$_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})" data-on:input__debounce.350ms="$_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
/> />
</div> </div>
<input type="text" data-bind:_cafe-search <input
type="text"
data-bind:_cafe-search
class="input-field w-full" class="input-field w-full"
placeholder="Search for a cafe&hellip;" placeholder="Search for a cafe&hellip;"
data-on:input__debounce.350ms="if ($_locationFound) { @get('/api/v1/nearby-cafes?lat=' + $_userLat + '&lng=' + $_userLng + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) } else { $_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) }" data-on:input__debounce.350ms="if ($_locationFound) { @get('/api/v1/nearby-cafes?lat=' + $_userLat + '&lng=' + $_userLng + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) } else { $_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) }"
/> />
<div id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"></div> <div
id="nearby-results"
class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"
></div>
</div> </div>
{% endmacro %} {% endmacro %}

View file

@ -1,23 +1,50 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
<nav class="border-b pb-4 text-sm"> <nav class="border-b pb-4 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-semibold uppercase tracking-[0.25em] text-accent"><a href="/" class="-m-2 inline-block p-2">B{rew}log</a></div> <div class="font-semibold uppercase tracking-[0.25em] text-accent">
<a href="/" class="-m-2 inline-block p-2">B{rew}log</a>
</div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{% if is_authenticated %} {% if is_authenticated %}
<a class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary" href="/add" title="Add" aria-label="Add"> <a
class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary"
href="/add"
title="Add"
aria-label="Add"
>
{{ icons::plus("h-5 w-5") }} {{ icons::plus("h-5 w-5") }}
</a> </a>
{% endif %} {% endif %}
<button type="button" class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary" title="Toggle theme" aria-label="Toggle theme" onclick="toggleTheme()"> <button
type="button"
class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary"
title="Toggle theme"
aria-label="Toggle theme"
onclick="toggleTheme()"
>
<span class="theme-icon-light">{{ icons::moon("h-5 w-5") }}</span> <span class="theme-icon-light">{{ icons::moon("h-5 w-5") }}</span>
<span class="theme-icon-dark hidden">{{ icons::sun("h-5 w-5") }}</span> <span class="theme-icon-dark hidden">{{ icons::sun("h-5 w-5") }}</span>
</button> </button>
{% if is_authenticated %} {% if is_authenticated %}
<a class="rounded-md p-1.5 transition {% if nav_active == "admin" %}text-accent{% else %}text-text-muted hover:text-text-secondary{% endif %}" href="/admin" title="Admin" aria-label="Admin"> <a
class="rounded-md p-1.5 transition {% if nav_active == "admin" %}
text-accent
{% else %}
text-text-muted hover:text-text-secondary
{% endif %}"
href="/admin"
title="Admin"
aria-label="Admin"
>
{{ icons::user("h-5 w-5") }} {{ icons::user("h-5 w-5") }}
</a> </a>
{% else %} {% else %}
<a class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary" href="/login" title="Login" aria-label="Login"> <a
class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary"
href="/login"
title="Login"
aria-label="Login"
>
{{ icons::user("h-5 w-5") }} {{ icons::user("h-5 w-5") }}
</a> </a>
{% endif %} {% endif %}
@ -43,7 +70,8 @@
}; };
const updateThemeIcons = () => { const updateThemeIcons = () => {
const isDark = document.documentElement.getAttribute("data-theme") === "dark"; const isDark =
document.documentElement.getAttribute("data-theme") === "dark";
document.querySelectorAll(".theme-icon-light").forEach((el) => { document.querySelectorAll(".theme-icon-light").forEach((el) => {
el.classList.toggle("hidden", isDark); el.classList.toggle("hidden", isDark);
}); });
@ -52,7 +80,9 @@
}); });
const favicon = document.getElementById("favicon"); const favicon = document.getElementById("favicon");
if (favicon) { if (favicon) {
favicon.href = isDark ? "/static/favicon-dark.svg" : "/static/favicon-light.svg"; favicon.href = isDark
? "/static/favicon-dark.svg"
: "/static/favicon-light.svg";
} }
}; };

View file

@ -1,8 +1,15 @@
<div id="nearby-results" class="mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"> <div
id="nearby-results"
class="mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"
>
{% if cafes.is_empty() %} {% if cafes.is_empty() %}
<p class="px-3 py-2 text-sm text-text-muted">No nearby cafes found.</p> <p class="px-3 py-2 text-sm text-text-muted">No nearby cafes found.</p>
{% else %} {% else %}
<h3 class="px-3 py-2 text-xs font-semibold text-text-muted uppercase tracking-wide">Nearby</h3> <h3
class="px-3 py-2 text-xs font-semibold text-text-muted uppercase tracking-wide"
>
Nearby
</h3>
{% for cafe in cafes %} {% for cafe in cafes %}
<button <button
type="button" type="button"
@ -16,7 +23,9 @@
data-on:click="$_cafeId = ''; $_cafeName = el.dataset.cafeName; $_cafeCity = el.dataset.cafeCity; $_cafeCountry = el.dataset.cafeCountry; $_cafeLat = parseFloat(el.dataset.cafeLat); $_cafeLng = parseFloat(el.dataset.cafeLng); $_cafeWebsite = el.dataset.cafeWebsite; $_reviewingCafe = true" data-on:click="$_cafeId = ''; $_cafeName = el.dataset.cafeName; $_cafeCity = el.dataset.cafeCity; $_cafeCountry = el.dataset.cafeCountry; $_cafeLat = parseFloat(el.dataset.cafeLat); $_cafeLng = parseFloat(el.dataset.cafeLng); $_cafeWebsite = el.dataset.cafeWebsite; $_reviewingCafe = true"
> >
<span class="font-medium text-text">{{ cafe.name }}</span> <span class="font-medium text-text">{{ cafe.name }}</span>
<span class="ml-2 text-xs text-text-muted">{{ cafe.location }} &middot; {{ cafe.distance }}</span> <span class="ml-2 text-xs text-text-muted"
>{{ cafe.location }} &middot; {{ cafe.distance }}</span
>
</button> </button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View file

@ -3,7 +3,9 @@
{% macro scan_input(form_id, image_input_id, extracting_signal, error_signal, placeholder, spinner_text) %} {% macro scan_input(form_id, image_input_id, extracting_signal, error_signal, placeholder, spinner_text) %}
<input type="hidden" name="image" id="{{ image_input_id }}" /> <input type="hidden" name="image" id="{{ image_input_id }}" />
<div data-show="!{{ extracting_signal }}" class="flex items-center gap-2"> <div data-show="!{{ extracting_signal }}" class="flex items-center gap-2">
<brew-photo-capture target-input="{{ image_input_id }}" target-form="{{ form_id }}" <brew-photo-capture
target-input="{{ image_input_id }}"
target-form="{{ form_id }}"
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 text-accent transition hover:bg-surface-alt cursor-pointer" class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 text-accent transition hover:bg-surface-alt cursor-pointer"
aria-label="Take Photo" aria-label="Take Photo"
> >
@ -17,9 +19,18 @@
placeholder="{{ placeholder }}" placeholder="{{ placeholder }}"
/> />
</div> </div>
<div data-show="{{ extracting_signal }}" style="display:none" class="flex items-center gap-3 text-sm text-accent"> <div
data-show="{{ extracting_signal }}"
style="display:none"
class="flex items-center gap-3 text-sm text-accent"
>
{{ icons::spinner("h-5 w-5") }} {{ icons::spinner("h-5 w-5") }}
{{ spinner_text }} {{ spinner_text }}
</div> </div>
<p data-show="{{ error_signal }}" data-text="{{ error_signal }}" style="display:none" class="mt-2 text-sm text-red-600"></p> <p
data-show="{{ error_signal }}"
data-text="{{ error_signal }}"
style="display:none"
class="mt-2 text-sm text-red-600"
></p>
{% endmacro %} {% endmacro %}

View file

@ -1,7 +1,9 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{% if geo_stats.entries.is_empty() %} {% if geo_stats.entries.is_empty() %}
<div class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"> <div
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
>
<p class="text-center">No data recorded yet.</p> <p class="text-center">No data recorded yet.</p>
</div> </div>
{% else %} {% else %}
@ -14,41 +16,63 @@
</div> </div>
<chip-scroll class="relative block"> <chip-scroll class="relative block">
<button type="button" aria-label="Scroll left" <button
type="button"
aria-label="Scroll left"
class="hidden absolute left-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute left-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 text-text-muted shadow-sm transition hover:text-text"
data-scroll-left data-scroll-left
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})"
>
{{ icons::chevron_left("h-4 w-4") }} {{ icons::chevron_left("h-4 w-4") }}
</button> </button>
<div class="flex gap-2 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide" data-chip-scroll> <div
class="flex gap-2 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide"
data-chip-scroll
>
{% for entry in geo_stats.entries %} {% for entry in geo_stats.entries %}
{% if !entry.iso_code.is_empty() %} {% if !entry.iso_code.is_empty() %}
<button type="button" <button
type="button"
class="inline-flex shrink-0 snap-start items-center rounded-full border bg-surface text-sm transition cursor-pointer hover:border-accent/40" class="inline-flex shrink-0 snap-start items-center rounded-full border bg-surface text-sm transition cursor-pointer hover:border-accent/40"
data-iso="{{ entry.iso_code|lower }}" data-iso="{{ entry.iso_code|lower }}"
onclick="const map = document.querySelector('world-map'); const iso = this.dataset.iso; const cur = map.getAttribute('data-selected'); if (cur === iso) { map.removeAttribute('data-selected'); } else { map.setAttribute('data-selected', iso); } document.querySelectorAll('[data-iso]').forEach(b => b.classList.toggle('bg-accent-subtle', b.dataset.iso === map.getAttribute('data-selected')));"> onclick="const map = document.querySelector('world-map'); const iso = this.dataset.iso; const cur = map.getAttribute('data-selected'); if (cur === iso) { map.removeAttribute('data-selected'); } else { map.setAttribute('data-selected', iso); } document.querySelectorAll('[data-iso]').forEach(b => b.classList.toggle('bg-accent-subtle', b.dataset.iso === map.getAttribute('data-selected')));"
>
<span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2"> <span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2">
{% if !entry.flag_emoji.is_empty() %} {% if !entry.flag_emoji.is_empty() %}
<span>{{ entry.flag_emoji }}</span> <span>{{ entry.flag_emoji }}</span>
{% endif %} {% endif %}
<span class="font-semibold text-highlight whitespace-nowrap">{{ entry.country_name }}</span> <span class="font-semibold text-highlight whitespace-nowrap"
>{{ entry.country_name }}</span
>
</span> </span>
<span class="border-l py-1.5 pl-2 pr-3 font-semibold text-highlight">{{ entry.count }}</span> <span
class="border-l py-1.5 pl-2 pr-3 font-semibold text-highlight"
>{{ entry.count }}</span
>
</button> </button>
{% else %} {% else %}
<div class="inline-flex shrink-0 snap-start items-center rounded-full border bg-surface text-sm"> <div
class="inline-flex shrink-0 snap-start items-center rounded-full border bg-surface text-sm"
>
<span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2"> <span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2">
<span class="text-text whitespace-nowrap">{{ entry.country_name }}</span> <span class="text-text whitespace-nowrap"
>{{ entry.country_name }}</span
>
</span> </span>
<span class="border-l py-1.5 pl-2 pr-3 font-medium text-text">{{ entry.count }}</span> <span class="border-l py-1.5 pl-2 pr-3 font-medium text-text"
>{{ entry.count }}</span
>
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
<button type="button" aria-label="Scroll right" <button
type="button"
aria-label="Scroll right"
class="hidden absolute right-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 text-text-muted shadow-sm transition hover:text-text" class="hidden absolute right-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 text-text-muted shadow-sm transition hover:text-text"
data-scroll-right data-scroll-right
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})"> onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})"
>
{{ icons::chevron_right("h-4 w-4") }} {{ icons::chevron_right("h-4 w-4") }}
</button> </button>
</chip-scroll> </chip-scroll>

View file

@ -1,6 +1,9 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
<div data-signals:{{ tab_signal }}="'{{ active_type }}'" data-signals:_tabs-open="false"> <div
data-signals:{{ tab_signal }}="'{{ active_type }}'"
data-signals:_tabs-open="false"
>
<!-- Desktop tabs --> <!-- Desktop tabs -->
<nav class="hidden md:flex gap-1.5" role="tablist"> <nav class="hidden md:flex gap-1.5" role="tablist">
{% for tab in tabs %} {% for tab in tabs %}
@ -9,8 +12,15 @@
role="tab" role="tab"
class="tab" class="tab"
data-class:tab-active="{{ tab_signal_js }} === '{{ tab.key }}'" data-class:tab-active="{{ tab_signal_js }} === '{{ tab.key }}'"
data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'{% if !tab_base_url.is_empty() %}; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}'); @get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides: {selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}}){% endif %}" data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'{% if !tab_base_url.is_empty() %}
>{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %} {{ tab.label }}</button> ; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}');
@get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides: {selector:
'{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
{% endif %}"
>
{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
{{ tab.label }}
</button>
{% endfor %} {% endfor %}
</nav> </nav>
<!-- Mobile tab selector --> <!-- Mobile tab selector -->
@ -22,20 +32,38 @@
> >
<span> <span>
{% for tab in tabs %} {% for tab in tabs %}
<span data-show="{{ tab_signal_js }} === '{{ tab.key }}'" class="inline-flex items-center gap-1.5">{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %} {{ tab.label }}</span> <span
data-show="{{ tab_signal_js }} === '{{ tab.key }}'"
class="inline-flex items-center gap-1.5"
>{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
{{ tab.label }}</span
>
{% endfor %} {% endfor %}
</span> </span>
<span data-show="!$_tabsOpen">{{ icons::chevron_down("h-5 w-5") }}</span> <span data-show="!$_tabsOpen">{{ icons::chevron_down("h-5 w-5") }}</span>
<span data-show="$_tabsOpen" style="display:none">{{ icons::chevron_up("h-5 w-5") }}</span> <span data-show="$_tabsOpen" style="display:none"
>{{ icons::chevron_up("h-5 w-5") }}</span
>
</button> </button>
<div class="mt-1 flex flex-col rounded-md border overflow-hidden" data-show="$_tabsOpen" style="display:none"> <div
class="mt-1 flex flex-col rounded-md border overflow-hidden"
data-show="$_tabsOpen"
style="display:none"
>
{% for tab in tabs %} {% for tab in tabs %}
<button <button
type="button" type="button"
class="tab-mobile" class="tab-mobile"
data-class:tab-mobile-active="{{ tab_signal_js }} === '{{ tab.key }}'" data-class:tab-mobile-active="{{ tab_signal_js }} === '{{ tab.key }}'"
data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'; $_tabsOpen = false{% if !tab_base_url.is_empty() %}; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}'); @get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides: {selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}}){% endif %}" data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'; $_tabsOpen = false{% if !tab_base_url.is_empty() %}
>{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %} {{ tab.label }}</button> ; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}');
@get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides:
{selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
{% endif %}"
>
{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
{{ tab.label }}
</button>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View file

@ -1,7 +1,16 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
<h2 id="{{ month.anchor }}" class="timeline-heading scroll-mt-24 mb-6 text-2xl font-semibold text-text" data-timeline-month> <h2
id="{{ month.anchor }}"
class="timeline-heading scroll-mt-24 mb-6 text-2xl font-semibold text-text"
data-timeline-month
>
{{ month.heading }} {{ month.heading }}
<button type="button" class="timeline-top-btn" onclick="window.scrollTo({top: 0, behavior: 'smooth'})" aria-label="Back to top"> <button
type="button"
class="timeline-top-btn"
onclick="window.scrollTo({top: 0, behavior: 'smooth'})"
aria-label="Back to top"
>
{{ icons::arrow_up("h-5 w-5") }} {{ icons::arrow_up("h-5 w-5") }}
</button> </button>
<button <button
@ -9,8 +18,14 @@
class="block mx-auto mt-1 text-xs font-normal text-text-muted uppercase tracking-wide hover:text-accent transition" class="block mx-auto mt-1 text-xs font-normal text-text-muted uppercase tracking-wide hover:text-accent transition"
data-on:click="if ($_expandedMonths.includes(',{{ month.anchor }}')) { $_expandedMonths = $_expandedMonths.replace(',{{ month.anchor }}', ''); $_expandedCard = ''; $_collapsedCards = '' } else { $_expandedMonths = $_expandedMonths + ',{{ month.anchor }}'; $_collapsedCards = '' }" data-on:click="if ($_expandedMonths.includes(',{{ month.anchor }}')) { $_expandedMonths = $_expandedMonths.replace(',{{ month.anchor }}', ''); $_expandedCard = ''; $_collapsedCards = '' } else { $_expandedMonths = $_expandedMonths + ',{{ month.anchor }}'; $_collapsedCards = '' }"
> >
<span data-show="!$_expandedMonths.includes(',{{ month.anchor }}')">Expand all</span> <span data-show="!$_expandedMonths.includes(',{{ month.anchor }}')"
<span data-show="$_expandedMonths.includes(',{{ month.anchor }}')" style="display:none">Collapse all</span> >Expand all</span
>
<span
data-show="$_expandedMonths.includes(',{{ month.anchor }}')"
style="display:none"
>Collapse all</span
>
</button> </button>
</h2> </h2>
{% for event in month.events %} {% for event in month.events %}
@ -30,25 +45,44 @@
{% if event.entity_type == "brew" %}{{ icons::beaker("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-3 w-3 shrink-0") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-3 w-3 shrink-0") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-3 w-3 shrink-0") }}{% endif %} {% if event.entity_type == "brew" %}{{ icons::beaker("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-3 w-3 shrink-0") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-3 w-3 shrink-0") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-3 w-3 shrink-0") }}{% endif %}
<span class="uppercase tracking-wide">{{ event.kind_label }}</span> <span class="uppercase tracking-wide">{{ event.kind_label }}</span>
{# Relative date — shown when collapsed #} {# Relative date — shown when collapsed #}
<span class="tl-condensed uppercase tracking-wide" data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"> · {{ event.relative_date_label }}</span> <span
class="tl-condensed uppercase tracking-wide"
data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"
>
· {{ event.relative_date_label }}</span
>
{# Full timestamp — shown when expanded #} {# Full timestamp — shown when expanded #}
<time <time
datetime="{{ event.iso_timestamp }}" datetime="{{ event.iso_timestamp }}"
class="tl-detail uppercase tracking-wide" class="tl-detail uppercase tracking-wide"
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')" data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
style="display:none" style="display:none"
> · {{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{% endif %}</time> >
·
{{ event.date_label }}{% if let Some(label) = event.time_label %}
· {{ label }}
{% endif %}</time
>
</span> </span>
{# Expand/collapse chevron #} {# Expand/collapse chevron #}
<span class="tl-chevron text-text-muted" data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"> <span
class="tl-chevron text-text-muted"
data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"
>
{{ icons::chevron_down("h-4 w-4") }} {{ icons::chevron_down("h-4 w-4") }}
</span> </span>
<span class="tl-chevron text-text-muted" data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')" style="display:none"> <span
class="tl-chevron text-text-muted"
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
style="display:none"
>
{{ icons::chevron_up("h-4 w-4") }} {{ icons::chevron_up("h-4 w-4") }}
</span> </span>
</div> </div>
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-text"> <h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-text">
<a href="{{ event.link }}" class="hover:text-accent">{{ event.title }}</a> <a href="{{ event.link }}" class="hover:text-accent"
>{{ event.title }}</a
>
{# External link — shown when expanded #} {# External link — shown when expanded #}
{% if let Some(url) = event.external_link %} {% if let Some(url) = event.external_link %}
<a <a
@ -70,7 +104,9 @@
<p <p
class="tl-condensed mt-1 text-sm text-text-muted" class="tl-condensed mt-1 text-sm text-text-muted"
data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')" data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"
>{{ sub }}</p> >
{{ sub }}
</p>
{% endif %} {% endif %}
{# Detail rows — shown when expanded #} {# Detail rows — shown when expanded #}
{% if event.details.len() > 0 %} {% if event.details.len() > 0 %}
@ -85,12 +121,26 @@
{% if detail.label.eq_ignore_ascii_case("position") && detail.link.is_some() %} {% if detail.label.eq_ignore_ascii_case("position") && detail.link.is_some() %}
<dd class="text-right flex items-center justify-end gap-1.5"> <dd class="text-right flex items-center justify-end gap-1.5">
{{ detail.value }} {{ detail.value }}
<a href="{{ detail.link.as_ref().unwrap() }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover" aria-label="View on map"> <a
href="{{ detail.link.as_ref().unwrap() }}"
target="_blank"
rel="noreferrer noopener"
class="text-accent hover:text-accent-hover"
aria-label="View on map"
>
{{ icons::map("h-4 w-4") }} {{ icons::map("h-4 w-4") }}
</a> </a>
</dd> </dd>
{% else if let Some(url) = detail.link %} {% else if let Some(url) = detail.link %}
<dd class="text-right"><a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover">{{ detail.value }}</a></dd> <dd class="text-right">
<a
href="{{ url }}"
target="_blank"
rel="noreferrer noopener"
class="text-accent hover:text-accent-hover"
>{{ detail.value }}</a
>
</dd>
{% else %} {% else %}
<dd class="text-right">{{ detail.value }}</dd> <dd class="text-right">{{ detail.value }}</dd>
{% endif %} {% endif %}
@ -99,7 +149,9 @@
{% if let Some(notes) = event.tasting_notes %} {% if let Some(notes) = event.tasting_notes %}
{% if !notes.is_empty() %} {% if !notes.is_empty() %}
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<dt class="shrink-0 font-medium text-text-muted">Tasting Notes</dt> <dt class="shrink-0 font-medium text-text-muted">
Tasting Notes
</dt>
<dd class="flex flex-wrap gap-1 justify-end"> <dd class="flex flex-wrap gap-1 justify-end">
{% for note in notes %} {% for note in notes %}
<span class="{{ note.pill_class }}">{{ note.label }}</span> <span class="{{ note.pill_class }}">{{ note.label }}</span>