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" />
<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
</brew-photo-capture>
<input name="prompt" type="text" placeholder="Or describe..." />
@ -697,7 +701,13 @@ Label + input pattern:
```html
<label class="flex flex-col gap-1 text-sm">
<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>
```
@ -787,7 +797,9 @@ List partials live in `templates/partials/lists/`. Each follows:
<div id="{entity}-list" class="mt-6" data-star-scope="{entity}">
{% 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>
</div>
{% else %}
@ -797,10 +809,15 @@ List partials live in `templates/partials/lists/`. Each follows:
...
</table>
{% if items.is_empty() %}
<div class="p-8 text-center text-text-muted">No {entities} match your search.</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>
<div class="p-8 text-center text-text-muted">
No {entities} match your search.
</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 %}
</section>
{% endif %}
@ -834,14 +851,18 @@ Tables use the `responsive-table` CSS class (card layout on mobile, standard tab
```html
<td data-label="Coffee" class="px-4 py-3 whitespace-nowrap">
<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>
```
**Mobile** — separate `<td>` for each sub-field:
```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

View file

@ -87,7 +87,13 @@
/* ── Typography ────────────────────────────────────────────────── */
body {
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family:
"Inter",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
a {
@ -125,7 +131,9 @@ a {
font-family: inherit;
font-size: 0.875rem;
color: var(--text);
transition: border-color 150ms ease, box-shadow 150ms ease;
transition:
border-color 150ms ease,
box-shadow 150ms ease;
}
input.input-field[type="number"]::-webkit-inner-spin-button,
@ -155,7 +163,9 @@ input.input-field[type="number"] {
background-color: var(--surface);
color: var(--accent);
font-weight: 700;
transition: background-color 150ms ease, color 150ms ease;
transition:
background-color 150ms ease,
color 150ms ease;
}
.btn-adjust:hover {
@ -343,7 +353,10 @@ input.input-field[type="number"] {
cursor: pointer;
border: 1px solid var(--border);
background-color: var(--surface);
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
transition:
background-color 150ms ease,
color 150ms ease,
border-color 150ms ease;
color: var(--accent);
}
@ -368,7 +381,9 @@ input.input-field[type="number"] {
border: none;
background: none;
color: var(--accent);
transition: background-color 150ms ease, color 150ms ease;
transition:
background-color 150ms ease,
color 150ms ease;
color: var(--text-secondary);
}
@ -452,7 +467,9 @@ input.input-field[type="number"] {
/* ── Data page ─────────────────────────────────────────────────── */
/* Hide duplicate search inside data page - search is in the tab bar */
.data-page-content section:has(.responsive-table) > div:first-child:has(input[type="search"]) {
.data-page-content
section:has(.responsive-table)
> div:first-child:has(input[type="search"]) {
display: none;
}

View file

@ -1,5 +1,9 @@
customElements.define("chip-scroll", class extends HTMLElement {
connectedCallback() { this._setup(); }
customElements.define(
"chip-scroll",
class extends HTMLElement {
connectedCallback() {
this._setup();
}
_setup() {
if (this._observer) this._observer.disconnect();
@ -16,7 +20,10 @@ customElements.define("chip-scroll", class extends HTMLElement {
return;
}
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 });
@ -27,4 +34,5 @@ customElements.define("chip-scroll", class extends HTMLElement {
update();
}
});
},
);

View file

@ -1,19 +1,31 @@
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" />',
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" />'
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" />',
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 {
static get observedAttributes() {
return ['data-items'];
return ["data-items"];
}
connectedCallback() {
requestAnimationFrame(() => this.render());
this._themeObserver = new MutationObserver(() => requestAnimationFrame(() => this.render()));
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
this._themeObserver = new MutationObserver(() =>
requestAnimationFrame(() => this.render()),
);
this._themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
}
disconnectedCallback() {
@ -26,22 +38,34 @@ class DonutChart extends HTMLElement {
}
render() {
const raw = this.dataset.items || '';
if (!raw) { this.innerHTML = ''; return; }
const raw = this.dataset.items || "";
if (!raw) {
this.innerHTML = "";
return;
}
const items = raw.split('|').map(s => {
const idx = s.lastIndexOf(':');
const items = raw
.split("|")
.map((s) => {
const idx = s.lastIndexOf(":");
if (idx === -1) return null;
const label = s.slice(0, idx).trim();
const count = parseInt(s.slice(idx + 1), 10);
return (label && count > 0) ? { label, count } : null;
}).filter(Boolean);
return label && count > 0 ? { label, count } : null;
})
.filter(Boolean);
if (items.length === 0) { this.innerHTML = ''; return; }
if (items.length === 0) {
this.innerHTML = "";
return;
}
const total = items.reduce((sum, i) => sum + i.count, 0);
const maxCount = items[0].count;
const rgb = getComputedStyle(document.documentElement).getPropertyValue('--highlight-rgb').trim() || '185, 28, 28';
const rgb =
getComputedStyle(document.documentElement)
.getPropertyValue("--highlight-rgb")
.trim() || "185, 28, 28";
const colorFor = (count) => {
const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2);
return `rgba(${rgb}, ${alpha})`;
@ -70,7 +94,7 @@ class DonutChart extends HTMLElement {
});
const legend = items.map((item, i) => {
const pct = Math.round(item.count / total * 100);
const pct = Math.round((item.count / total) * 100);
return `<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 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">
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="display:block">
${segments.join('')}
${segments.join("")}
${(() => {
const icon = DONUT_ICONS[this.dataset.icon];
if (!icon) return '';
if (!icon) return "";
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>
<div style="display:flex;flex-direction:column;gap:0.375rem;width:100%">
${legend.join('')}
${legend.join("")}
</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() {
const input = document.createElement("input");
input.type = "file";
@ -15,11 +17,15 @@ customElements.define("brew-photo-capture", class extends HTMLElement {
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
document.getElementById(this.getAttribute("target-input")).value = reader.result;
document.getElementById(this.getAttribute("target-form")).requestSubmit();
document.getElementById(this.getAttribute("target-input")).value =
reader.result;
document
.getElementById(this.getAttribute("target-form"))
.requestSubmit();
};
reader.readAsDataURL(file);
input.value = "";
});
}
});
},
);

View file

@ -1,4 +1,6 @@
customElements.define("searchable-select", class extends HTMLElement {
customElements.define(
"searchable-select",
class extends HTMLElement {
connectedCallback() {
requestAnimationFrame(() => this._setup());
}
@ -8,7 +10,8 @@ customElements.define("searchable-select", class extends HTMLElement {
this._initialized = true;
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 hidden = document.createElement("input");
@ -21,7 +24,8 @@ customElements.define("searchable-select", class extends HTMLElement {
search.placeholder = placeholder;
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));
const searchWrap = document.createElement("div");
@ -33,8 +37,10 @@ customElements.define("searchable-select", class extends HTMLElement {
const clear = document.createElement("button");
clear.type = "button";
clear.className = "absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition";
clear.innerHTML = '<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>';
clear.className =
"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");
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
// CustomEvent (which carries evt.detail) reaches data-on:change.
this.addEventListener("change", (e) => {
this.addEventListener(
"change",
(e) => {
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
}, true);
},
true,
);
this.textContent = "";
this.style.display = "block";
@ -68,7 +78,9 @@ customElements.define("searchable-select", class extends HTMLElement {
const q = search.value.toLowerCase();
options.classList.toggle("hidden", !q);
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");
selectedWrap.classList.remove("hidden");
this.dispatchEvent(new CustomEvent("change", {
detail: { value: btn.value, display: btn.dataset.display, data: { ...btn.dataset } },
this.dispatchEvent(
new CustomEvent("change", {
detail: {
value: btn.value,
display: btn.dataset.display,
data: { ...btn.dataset },
},
bubbles: true,
}));
}),
);
});
const doClear = () => {
@ -101,4 +119,5 @@ customElements.define("searchable-select", class extends HTMLElement {
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++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
};
// Convert server challenge options to format navigator.credentials expects
@ -26,7 +29,7 @@ const prepareCreationOptions = (options) => {
publicKey.user.id = base64urlToBuffer(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map((cred) =>
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) })
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }),
);
}
return options;
@ -37,7 +40,7 @@ const prepareRequestOptions = (options) => {
publicKey.challenge = base64urlToBuffer(publicKey.challenge);
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map((cred) =>
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) })
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }),
);
}
return options;
@ -67,7 +70,9 @@ const serializeAuthenticationCredential = (credential) => {
authenticatorData: bufferToBase64url(response.authenticatorData),
clientDataJSON: bufferToBase64url(response.clientDataJSON),
signature: bufferToBase64url(response.signature),
userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null,
userHandle: response.userHandle
? bufferToBase64url(response.userHandle)
: null,
},
};
};
@ -78,13 +83,18 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
const startResponse = await fetch("/api/v1/webauthn/register/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, display_name: displayName, passkey_name: passkeyName }),
body: JSON.stringify({
token,
display_name: displayName,
passkey_name: passkeyName,
}),
});
if (!startResponse.ok) {
const status = startResponse.status;
if (status === 401) throw new Error("Invalid registration token.");
if (status === 410) throw new Error("Registration token has expired or already been used.");
if (status === 410)
throw new Error("Registration token has expired or already been used.");
throw new Error(`Failed to start registration (HTTP ${status}).`);
}
@ -106,7 +116,9 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
});
if (!finishResponse.ok) {
throw new Error(`Failed to complete registration (HTTP ${finishResponse.status}).`);
throw new Error(
`Failed to complete registration (HTTP ${finishResponse.status}).`,
);
}
return finishResponse.json();
@ -120,7 +132,8 @@ const startPasskeyAuthentication = async (queryParams) => {
if (!startResponse.ok) {
const status = startResponse.status;
if (status === 404) throw new Error("No passkeys registered. Please register first.");
if (status === 404)
throw new Error("No passkeys registered. Please register first.");
throw new Error(`Failed to start authentication (HTTP ${status}).`);
}
@ -157,7 +170,9 @@ const addPasskey = async (name) => {
});
if (!startResponse.ok) {
throw new Error(`Failed to start passkey registration (HTTP ${startResponse.status}).`);
throw new Error(
`Failed to start passkey registration (HTTP ${startResponse.status}).`,
);
}
const { challenge_id, options } = await startResponse.json();
@ -178,6 +193,8 @@ const addPasskey = async (name) => {
});
if (!finishResponse.ok) {
throw new Error(`Failed to complete passkey registration (HTTP ${finishResponse.status}).`);
throw new Error(
`Failed to complete passkey registration (HTTP ${finishResponse.status}).`,
);
}
};

View file

@ -5,26 +5,44 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
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:site_name" content="Brewlog" />
<meta 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
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" />
<title>{% block title %}Brewlog{% endblock %}</title>
<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>
;(() => {
const stored = localStorage.getItem("theme")
(() => {
const stored = localStorage.getItem("theme");
const isDark =
stored === "dark" || (!stored && matchMedia("(prefers-color-scheme: dark)").matches)
stored === "dark" ||
(!stored && matchMedia("(prefers-color-scheme: dark)").matches);
if (isDark) {
document.documentElement.setAttribute("data-theme", "dark")
document.getElementById("favicon").href = "/static/favicon-dark.svg"
document.documentElement.setAttribute("data-theme", "dark");
document.getElementById("favicon").href = "/static/favicon-dark.svg";
}
})()
})();
</script>
<script
type="module"
@ -38,95 +56,110 @@
{% block head %}{% endblock %}
<script>
const toggleRow = (event) => {
if (event.target.closest(".card-detail")) return
const row = event.currentTarget
row.classList.toggle("expanded")
row.querySelector(".card-detail")?.classList.toggle("hidden")
const r = row.nextElementSibling
if (r?.classList.contains("detail-row")) r.classList.toggle("hidden")
row.querySelector(".icon-expand")?.classList.toggle("hidden")
row.querySelector(".icon-collapse")?.classList.toggle("hidden")
}
if (event.target.closest(".card-detail")) return;
const row = event.currentTarget;
row.classList.toggle("expanded");
row.querySelector(".card-detail")?.classList.toggle("hidden");
const r = row.nextElementSibling;
if (r?.classList.contains("detail-row")) r.classList.toggle("hidden");
row.querySelector(".icon-expand")?.classList.toggle("hidden");
row.querySelector(".icon-collapse")?.classList.toggle("hidden");
};
</script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const mobileQuery = window.matchMedia("(max-width: 767px)")
if (!mobileQuery.matches) return
const mobileQuery = window.matchMedia("(max-width: 767px)");
if (!mobileQuery.matches) return;
const setupInfiniteScroll = () => {
document.querySelectorAll("[data-infinite-scroll]").forEach((container) => {
const sentinel = container.querySelector(".infinite-scroll-sentinel")
if (!sentinel || sentinel.dataset.observed) return
sentinel.dataset.observed = "true"
document
.querySelectorAll("[data-infinite-scroll]")
.forEach((container) => {
const sentinel = container.querySelector(
".infinite-scroll-sentinel",
);
if (!sentinel || sentinel.dataset.observed) return;
sentinel.dataset.observed = "true";
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
if (!entry.isIntersecting) return;
const nextUrl = container.getAttribute("data-next-url")
const targetSelector = container.getAttribute("data-target")
const nextUrl = container.getAttribute("data-next-url");
const targetSelector =
container.getAttribute("data-target");
if (!nextUrl || !targetSelector) {
observer.disconnect()
return
observer.disconnect();
return;
}
observer.disconnect()
sentinel.remove()
observer.disconnect();
sentinel.remove();
fetch(nextUrl, {
headers: { "datastar-request": "true" },
})
.then((res) => res.text())
.then((html) => {
const doc = new DOMParser().parseFromString(html, "text/html")
const newTarget = doc.querySelector(targetSelector)
if (!newTarget) return
const doc = new DOMParser().parseFromString(
html,
"text/html",
);
const newTarget = doc.querySelector(targetSelector);
if (!newTarget) return;
// Append new rows to existing tbody
const existingTbody = container.querySelector("tbody")
const newTbody = newTarget.querySelector("tbody")
const existingTbody = container.querySelector("tbody");
const newTbody = newTarget.querySelector("tbody");
if (existingTbody && newTbody) {
Array.from(newTbody.children).forEach((row) => {
existingTbody.appendChild(document.adoptNode(row))
})
existingTbody.appendChild(document.adoptNode(row));
});
}
// Check if there are more pages
const newContainer = newTarget.querySelector("[data-infinite-scroll]")
const newNextUrl = newContainer && newContainer.getAttribute("data-next-url")
const newContainer = newTarget.querySelector(
"[data-infinite-scroll]",
);
const newNextUrl =
newContainer &&
newContainer.getAttribute("data-next-url");
if (newNextUrl) {
container.setAttribute("data-next-url", newNextUrl)
const newSentinel = document.createElement("div")
newSentinel.className = "infinite-scroll-sentinel h-4 md:hidden"
newSentinel.setAttribute("aria-hidden", "true")
container.appendChild(newSentinel)
setupInfiniteScroll()
container.setAttribute("data-next-url", newNextUrl);
const newSentinel = document.createElement("div");
newSentinel.className =
"infinite-scroll-sentinel h-4 md:hidden";
newSentinel.setAttribute("aria-hidden", "true");
container.appendChild(newSentinel);
setupInfiniteScroll();
} else {
container.removeAttribute("data-next-url")
container.removeAttribute("data-infinite-scroll")
container.removeAttribute("data-next-url");
container.removeAttribute("data-infinite-scroll");
}
})
})
});
});
},
{ rootMargin: "200px" }
)
{ rootMargin: "200px" },
);
observer.observe(sentinel)
})
}
observer.observe(sentinel);
});
};
setupInfiniteScroll()
setupInfiniteScroll();
const bodyObserver = new MutationObserver(() => {
setupInfiniteScroll()
})
bodyObserver.observe(document.body, { childList: true, subtree: true })
})
setupInfiniteScroll();
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
});
</script>
</head>
<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 %}
<footer
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

@ -1,32 +1,47 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Admin{% endblock %}
{% block head %}
<script src="/static/js/webauthn.js"></script>
<script src="/static/js/webauthn.js"></script>
{% endblock %}
{% block content %}
<header class="flex flex-col gap-2">
<header class="flex flex-col gap-2">
<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>
</header>
<p class="max-w-2xl text-sm text-text-secondary">
Manage passkeys, API tokens, and data.
</p>
</header>
<!-- Passkeys -->
<section data-signals:_show-passkey-form="false" class="rounded-lg border bg-surface p-5">
<!-- Passkeys -->
<section
data-signals:_show-passkey-form="false"
class="rounded-lg border bg-surface p-5"
>
<div class="flex flex-col gap-4">
<div>
<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 class="flex flex-col gap-2">
{% 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">
{{ icons::key("h-4 w-4 text-accent shrink-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">
Added {{ passkey.created_at }}
· {% if let Some(last_used) = passkey.last_used_at %}Last used {{ last_used }}{% else %}Never used{% endif %}
Added {{ passkey.created_at }} ·
{% if let Some(last_used) = passkey.last_used_at %}
Last used {{ last_used }}
{% else %}
Never used
{% endif %}
</span>
</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"
onclick="deletePasskey({{ passkey.id }}, '{{ passkey.name }}')"
>
{{ icons::delete("h-4 w-4") }}
Delete
{{ icons::delete("h-4 w-4") }} Delete
</button>
{% endif %}
</div>
@ -45,13 +59,22 @@
</div>
{% 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 %}
<!-- Add passkey form -->
<div id="add-passkey-form" 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
id="add-passkey-form"
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">
<label class="flex-1 flex flex-col gap-1 text-sm">
<span class="text-text">Passkey Name</span>
@ -79,9 +102,12 @@
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
<div id="add-passkey-loading" 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
id="add-passkey-loading"
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>
@ -92,26 +118,27 @@
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"
>
{{ icons::key("h-4 w-4") }}
New Passkey
{{ icons::key("h-4 w-4") }} New Passkey
</button>
</div>
</div>
</section>
</section>
<!-- API Tokens -->
<section
<!-- API Tokens -->
<section
class="rounded-lg border bg-surface p-5"
data-signals:_show-token-form="false"
data-signals:_creating-token="false"
data-signals:_token-created="false"
data-signals:_token-value="''"
data-signals:_token-error="''"
>
>
<div class="flex flex-col gap-4">
<div>
<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>
{% if tokens.is_empty() %}
@ -119,14 +146,23 @@
{% else %}
<div class="flex flex-col gap-2">
{% 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">
{{ icons::terminal("h-4 w-4 text-accent shrink-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">
Created {{ token.created_at }}
· {% if let Some(last_used) = token.last_used_at %}Last used {{ last_used }}{% else %}Never used{% endif %}
Created {{ token.created_at }} ·
{% if let Some(last_used) = token.last_used_at %}
Last used {{ last_used }}
{% else %}
Never used
{% endif %}
</span>
</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"
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
>
{{ icons::delete("h-4 w-4") }}
Revoke
{{ icons::delete("h-4 w-4") }} Revoke
</button>
</div>
{% endfor %}
@ -144,10 +179,17 @@
{% endif %}
<!-- Create token form -->
<div class="rounded-md border bg-surface-alt p-4"
data-show="$_showTokenForm && !$_tokenCreated" 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>
<div
class="rounded-md border bg-surface-alt p-4"
data-show="$_showTokenForm && !$_tokenCreated"
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
data-on:submit="$_creatingToken = true; $_tokenError = ''; @post('/api/v1/tokens', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_creatingToken) return;
@ -185,8 +227,11 @@
</div>
<!-- One-time token display -->
<div 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">
<div
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
type="button"
onclick="window.location.reload()"
@ -195,10 +240,15 @@
>
{{ icons::x_mark("h-4 w-4") }}
</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">
<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>
<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
type="button"
onclick="copyToken(this)"
@ -216,19 +266,21 @@
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"
>
{{ icons::terminal("h-4 w-4") }}
New Token
{{ icons::terminal("h-4 w-4") }} New Token
</button>
</div>
</div>
</section>
</section>
<!-- Data -->
<section class="rounded-lg border bg-surface p-5">
<!-- Data -->
<section class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-4">
<div>
<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 class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
@ -237,16 +289,14 @@
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"
>
{{ icons::arrow_down_tray("h-4 w-4") }}
New Backup
{{ icons::arrow_down_tray("h-4 w-4") }} New Backup
</a>
<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"
onclick="document.getElementById('restore-file-input').click()"
>
{{ icons::arrow_up_tray("h-4 w-4") }}
Restore
{{ icons::arrow_up_tray("h-4 w-4") }} Restore
</button>
<button
type="button"
@ -258,8 +308,12 @@
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"
>
<span data-show="!$_recomputing">{{ icons::refresh("h-4 w-4") }}</span>
<span data-show="$_recomputing" style="display:none">{{ icons::spinner("h-4 w-4") }}</span>
<span data-show="!$_recomputing"
>{{ icons::refresh("h-4 w-4") }}</span
>
<span data-show="$_recomputing" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span
>
Recompute Stats
</button>
<button
@ -267,45 +321,61 @@
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()"
>
{{ icons::delete("h-4 w-4") }}
Reset
{{ icons::delete("h-4 w-4") }} Reset
</button>
</div>
<input type="file" id="restore-file-input" accept=".json" class="hidden"
onchange="restoreFromFile(this)" />
<input
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 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
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>
</section>
</section>
<!-- AI Usage -->
{% if let Some(usage) = ai_usage %}
<section class="rounded-lg border bg-surface p-5">
<!-- AI Usage -->
{% if let Some(usage) = ai_usage %}
<section class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-text">AI Usage</h2>
<div class="grid grid-cols-3 gap-4">
<div>
<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>
<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>
<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>
</section>
{% endif %}
</section>
{% endif %}
<!-- Sign Out -->
<section class="rounded-lg border bg-surface p-5">
<!-- Sign Out -->
<section class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-4">
<div>
<h2 class="text-lg font-semibold text-text">Sign Out</h2>
@ -313,16 +383,18 @@
</div>
<div>
<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">
{{ icons::logout("h-4 w-4") }}
Sign Out
<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"
>
{{ icons::logout("h-4 w-4") }} Sign Out
</button>
</form>
</div>
</div>
</section>
</section>
<script>
<script>
// --- Passkey form ---
const registerPasskey = async () => {
@ -359,7 +431,9 @@
if (navigator.clipboard) {
navigator.clipboard.writeText(token).then(() => {
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
setTimeout(() => {
btn.textContent = "Copy";
}, 2000);
});
} else {
const range = document.createRange();
@ -377,7 +451,11 @@
if (!file) return;
input.value = "";
if (!confirm("Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.")) {
if (
!confirm(
"Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.",
)
) {
return;
}
@ -397,7 +475,9 @@
});
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) {
throw new Error(`Restore failed (HTTP ${response.status}).`);
@ -406,13 +486,18 @@
status.textContent = "Backup restored successfully.";
status.classList.remove("hidden");
} 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");
}
};
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;
}
@ -426,13 +511,16 @@
error.classList.add("hidden");
try {
const response = await fetch("/api/v1/backup/reset", { method: "POST" });
const response = await fetch("/api/v1/backup/reset", {
method: "POST",
});
if (!response.ok) {
throw new Error(`Reset failed (HTTP ${response.status}).`);
}
status.textContent = "Database reset successfully. All coffee data has been deleted.";
status.textContent =
"Database reset successfully. All coffee data has been deleted.";
status.classList.remove("hidden");
} catch (err) {
error.textContent = err.message;
@ -444,7 +532,9 @@
if (!confirm(`Delete passkey "${name}"? This cannot be undone.`)) return;
try {
const response = await fetch(`/api/v1/passkeys/${id}`, { method: "DELETE" });
const response = await fetch(`/api/v1/passkeys/${id}`, {
method: "DELETE",
});
if (response.ok) {
window.location.reload();
} else if (response.status === 409) {
@ -461,7 +551,9 @@
if (!confirm(`Revoke token "${name}"? This cannot be undone.`)) return;
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) {
const row = document.getElementById(`token-row-${id}`);
if (row) row.remove();
@ -472,5 +564,5 @@
alert(`Failed to revoke token: ${err.message}`);
}
};
</script>
</script>
{% endblock %}

View file

@ -2,28 +2,37 @@
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %}
{% block description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %}
{% block description %}
{{ bag.roast_name }}
by {{ bag.roaster_name }} — {{ bag.amount }} bag.
{% endblock %}
{% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %}
{% block og_description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
{% block og_description %}
{{ 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 %}
<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">
<h1 class="text-3xl font-semibold">{{ bag.roast_name }}</h1>
<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>
</div>
</header>
</header>
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(bag.roast_name, bag.roaster_name, bag.origin, bag.origin_flag, bag.region, bag.producer, bag.process, bag.tasting_notes) }}
{{ detail::map_with_legend_2(bag.map_countries, bag.map_max, "Origin", "", "Roaster", "opacity-50") }}
</div>
</div>
{# ── Roaster & bag info ── #}
<div class="grid gap-6 md:grid-cols-2">
{# ── Roaster & bag info ── #}
<div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(bag.roaster_name, bag.roaster_country, bag.roaster_country_flag, bag.roaster_city, bag.roaster_homepage) }}
<div class="rounded-lg border bg-surface p-5">
@ -41,9 +50,14 @@
{% else %}
<div class="mt-1">
<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>
<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>
{% endif %}
</dd>
@ -66,25 +80,31 @@
{% endif %}
</dl>
</div>
</div>
</div>
{% if is_authenticated %}
{# ── Actions ── #}
<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">
{% if is_authenticated %}
{# ── Actions ── #}
<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"
>
{% 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"
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
</button>
{% 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"
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
</button>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -2,28 +2,39 @@
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
{% block description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
{% block description %}
{{ brew.roast_name }}
by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee,
{{ brew.water_volume }} water.
{% endblock %}
{% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
{% block og_description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
{% block og_description %}
{{ 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 %}
<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">
<h1 class="text-3xl font-semibold">{{ brew.roast_name }}</h1>
<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>
</div>
</header>
</header>
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(brew.roast_name, brew.roaster_name, brew.origin, brew.origin_flag, brew.region, brew.producer, brew.process, brew.tasting_notes) }}
{{ detail::map_with_legend_2(brew.map_countries, brew.map_max, "Origin", "", "Roaster", "opacity-50") }}
</div>
</div>
{# ── Roaster & gear ── #}
<div class="grid gap-6 md:grid-cols-2">
{# ── Roaster & gear ── #}
<div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(brew.roaster_name, brew.roaster_country, brew.roaster_country_flag, brew.roaster_city, brew.roaster_homepage) }}
<div class="rounded-lg border bg-surface p-5">
@ -45,10 +56,10 @@
{% endif %}
</dl>
</div>
</div>
</div>
{# ── Recipe ── #}
<div class="grid gap-6 md:grid-cols-2">
{# ── Recipe ── #}
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Recipe</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
@ -82,15 +93,17 @@
{% endif %}
</dl>
</div>
</div>
</div>
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button"
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<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"
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
</button>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -3,19 +3,25 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ cafe.name }}{% endblock %}
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
{% block og_description %}{{ cafe.name }} — {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }}{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
{% block og_description %}
{{ 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 %}
<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">
<h1 class="text-3xl font-semibold">{{ cafe.name }}</h1>
<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>
</div>
</header>
</header>
<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">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
@ -26,14 +32,20 @@
<div>
<dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text">
{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }}
{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }}
</dd>
</div>
{% if let Some(url) = cafe.website %}
<div>
<dt class="text-text-muted">Website</dt>
<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>
</div>
{% endif %}
@ -41,15 +53,17 @@
</div>
{{ detail::map_with_legend_1(cafe.map_countries, cafe.map_max, "Cafe", "") }}
</div>
</div>
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button"
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<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"
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
</button>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -1,11 +1,23 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import
"partials/scan_input.html" as scan %} {% import "partials/location_search.html" as location %} {%
block title %}Brewlog · Check In{% endblock %} {% block head %}
<script>
{{ location::location_search_js() }}
</script>
{% endblock %} {% block content %}
<section
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{%
import
"partials/scan_input.html" as scan
%}
{% import "partials/location_search.html" as location %}
{% block title %}
Brewlog · Check In
{% endblock %}
{% block head %}
<script>
{
{
location::location_search_js();
}
}
</script>
{% endblock %}
{% block content %}
<section
id="checkin-root"
data-signals:_step="1"
data-signals:_cafe-id="''"
@ -30,16 +42,21 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
data-signals:_extracting="false"
data-signals:_extract-error="''"
data-signals:_scan-success="''"
>
>
<header class="flex flex-col gap-2">
<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>
<!-- Step progress -->
<div class="relative mx-auto mt-6" style="width: 80%">
<!-- 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 -->
<div
class="absolute bg-accent"
@ -153,16 +170,27 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
<!-- Step 1: Cafe selection -->
<div class="mt-4" data-show="$_step === 1" style="display: none">
<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 -->
<div data-show="$_reviewingCafe" style="display: none">
<h3 class="text-base font-semibold text-text mb-1">Confirm cafe details</h3>
<p class="text-sm text-text-secondary mb-3">Review and edit the details before saving.</p>
<h3 class="text-base font-semibold text-text mb-1">
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">
<label class="flex flex-col gap-1 text-sm">
<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 class="flex flex-col gap-1 text-sm">
<span class="text-text">City</span>
@ -203,7 +231,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
<!-- Saved cafes -->
{% if !cafe_options.is_empty() %}
<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
name="saved_cafe_id"
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"
>
<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>
{% endfor %}
</searchable-select>
@ -234,7 +266,8 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
<div data-show="!$_scanSuccess" class="mb-4">
<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>
<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: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>
</div>
@ -257,7 +292,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
{% if !roast_options.is_empty() %}
<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
name="roast_id"
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"
>
<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>
{% endfor %}
</searchable-select>
@ -284,7 +323,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
<!-- Step 3: Review + submit -->
<div class="mt-4" data-show="$_step === 3" style="display: none">
<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">
<div class="flex justify-between gap-2">
<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_name" data-attr:value="$_cafeName" />
<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_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" />
<button
type="submit"
@ -324,5 +373,5 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
</form>
</div>
</div>
</section>
</section>
{% endblock %}

View file

@ -1,21 +1,26 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · CLI Authentication{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
<div class="mx-auto max-w-md">
<div class="rounded-lg border bg-surface p-6">
{% if token.is_some() %}
<div class="flex flex-col items-center text-center gap-3">
{{ 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>
<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>
</div>
{% else if error.is_some() %}
<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") }}
<h1 class="text-2xl font-semibold text-accent">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">
<h1 class="text-2xl font-semibold text-accent">
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() }}
</div>
</div>
@ -29,5 +34,5 @@
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View file

@ -2,28 +2,37 @@
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
{% block description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
{% block description %}
{{ cup.roast_name }}
by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.
{% endblock %}
{% block og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %}
{% block og_description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
{% block og_description %}
{{ 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 %}
<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">
<h1 class="text-3xl font-semibold">{{ cup.roast_name }}</h1>
<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>
</div>
</header>
</header>
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(cup.roast_name, cup.roaster_name, cup.origin, cup.origin_flag, cup.region, cup.producer, cup.process, cup.tasting_notes) }}
{{ detail::map_with_legend_3(cup.map_countries, cup.map_max, "Cafe", "", "Origin", "opacity-65", "Roaster", "opacity-35") }}
</div>
</div>
{# ── Roaster & cafe ── #}
<div class="grid gap-6 md:grid-cols-2">
{# ── Roaster & cafe ── #}
<div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(cup.roaster_name, cup.roaster_country, cup.roaster_country_flag, cup.roaster_city, cup.roaster_homepage) }}
<div class="rounded-lg border bg-surface p-5">
@ -36,7 +45,7 @@
<div>
<dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text">
{% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }} {% endif %}{{ cup.cafe_country }}
{% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }}{% endif %}{{ cup.cafe_country }}
</dd>
</div>
<div>
@ -47,21 +56,29 @@
<div>
<dt class="text-text-muted">Website</dt>
<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>
</div>
{% endif %}
</dl>
</div>
</div>
</div>
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button"
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<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"
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
</button>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -1,11 +1,12 @@
{% 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 %}
<header class="flex flex-col gap-2">
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Data</h1>
<p class="max-w-2xl text-sm text-text-secondary">Browse all coffee data.</p>
</header>
</header>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4">
{% include "partials/tab_bar.html" %}
<!-- Search -->
<div data-signals:_data-search="'{{ search_value }}'">
@ -19,10 +20,8 @@
/>
</div>
<div id="data-content" class="data-page-content">
{{ content|safe }}
<div id="data-content" class="data-page-content">{{ content|safe }}</div>
</div>
</div>
<div id="detail-panel"></div>
<div id="detail-panel"></div>
{% endblock %}

View file

@ -2,19 +2,25 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
{% block og_description %}{{ gear.make }} {{ gear.model }} — {{ gear.category_label }}{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
{% block og_description %}
{{ gear.make }}
{{ gear.model }}
— {{ gear.category_label }}
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% 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">
<h1 class="text-3xl font-semibold">{{ gear.make }} {{ gear.model }}</h1>
<p class="text-sm text-text-secondary">
{{ gear.category_label }} · Added {{ gear.created_date }}
</p>
</div>
</header>
</header>
<div class="rounded-lg border bg-surface p-5">
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
@ -30,15 +36,17 @@
<dd class="font-medium text-text">{{ gear.model }}</dd>
</div>
</dl>
</div>
</div>
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button"
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<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"
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
</button>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -1,10 +1,17 @@
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import
"partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %} {% block content %}
<!-- Scan Bag -->
{% if is_authenticated %}
<section
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %}
{%
import
"partials/brew_card.html" as brew_card
%}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
{% endblock %}
{% block content %}
<!-- Scan Bag -->
{% if is_authenticated %}
<section
data-signals:_extracting="false"
data-signals:_extract-error="''"
data-signals:_scan-extracted="false"
@ -24,23 +31,33 @@
data-signals:_bag-amount="250"
data-signals:_matched-roaster-id="''"
data-signals:_matched-roast-id="''"
>
>
<!-- Quick actions (shown when not yet extracted) -->
<div data-show="!$_scanExtracted">
<div data-show="!$_extracting" class="grid grid-cols-3 gap-4">
<input type="hidden" name="image" id="scan-image" form="scan-extract-form" />
<form id="scan-extract-form"
<input
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: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>
<brew-photo-capture target-input="scan-image" target-form="scan-extract-form"
class="hidden"
></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"
aria-label="Scan Bag"
>
{{ icons::camera("h-5 w-5") }}
<span class="text-sm font-medium">Scan</span>
</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"
title="Check in at a cafe"
aria-label="Check in at a cafe"
@ -48,7 +65,8 @@
{{ icons::location("h-5 w-5") }}
<span class="text-sm font-medium">Check In</span>
</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"
title="Add manually"
aria-label="Add manually"
@ -57,11 +75,19 @@
<span class="text-sm font-medium">Add</span>
</a>
</div>
<div data-show="$_extracting" 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
data-show="$_extracting"
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>
<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>
<!-- Form: pre-filled roaster + roast (shown after extraction) -->
@ -74,40 +100,56 @@
{% include "partials/forms/scan_result_form.html" %}
</form>
</div>
</section>
{% endif %}
</section>
{% endif %}
<!-- Recent Brews -->
<section data-signals:_brewing="false">
<!-- Recent Brews -->
<section data-signals:_brewing="false">
<div class="flex items-center justify-between mb-3">
<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
>
</div>
{% if !recent_brews.is_empty() %}
<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"
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") }}
</button>
<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 %}
<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 %}
</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"
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") }}
</button>
</chip-scroll>
{% else %}
<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 %}
<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 w-3/4 rounded bg-surface-alt"></div>
<div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div>
@ -123,26 +165,33 @@
{% endfor %}
</div>
<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>
{% endif %}
</section>
</section>
<!-- Open Bags -->
<section id="open-bags-section">
<!-- Open Bags -->
<section id="open-bags-section">
<div class="flex items-center justify-between mb-3">
<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
>
</div>
{% if !open_bags.is_empty() %}
<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"
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") }}
</button>
<div
@ -150,20 +199,28 @@
class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide"
data-chip-scroll
>
{% 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>
<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"
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") }}
</button>
</chip-scroll>
{% else %}
<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 %}
<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="h-4 w-3/4 rounded bg-surface-alt"></div>
<div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div>
@ -180,31 +237,40 @@
{% endfor %}
</div>
<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>
{% endif %}
</section>
</section>
<!-- Recent Activity -->
<section>
<!-- Recent Activity -->
<section>
<div class="flex items-center justify-between mb-3">
<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
>
</div>
{% if !recent_events.is_empty() %}
<div class="space-y-2">
{% 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">
<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 %}
</span>
<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="text-xs text-text-muted">· {{ sub }}</span>{% endif %}
<span class="font-medium text-text">{{ event.title }}</span
>{% if let Some(sub) = event.subtitle %}
<span class="text-xs text-text-muted">· {{ sub }}</span>
{% endif %}
</p>
</div>
<time class="text-xs text-text-muted whitespace-nowrap shrink-0"
@ -215,9 +281,14 @@
</div>
{% else %}
<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 %}
<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="h-5 w-5 shrink-0 rounded-full bg-surface-alt"></div>
<div class="h-4 w-40 rounded bg-surface-alt"></div>
@ -227,49 +298,74 @@
{% endfor %}
</div>
<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>
{% endif %}
</section>
</section>
<!-- Stats -->
<section>
<!-- Stats -->
<section>
<div class="flex items-center justify-between mb-5">
<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
>
</div>
{% if !stats.is_empty() && !stat_cards.is_empty() %}
<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"
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") }}
</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 %}
<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 %}
<span class="text-lg font-bold text-text whitespace-nowrap">{{ card.value }}</span>
<span class="text-sm font-medium text-accent">{{ card.label }}</span>
<span class="text-lg font-bold text-text whitespace-nowrap"
>{{ card.value }}</span
>
<span class="text-sm font-medium text-accent"
>{{ card.label }}</span
>
</a>
{% endfor %}
</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"
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") }}
</button>
</chip-scroll>
{% else %}
<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 %}
<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-5 w-16 rounded bg-surface-alt"></div>
<div class="h-4 w-20 rounded bg-surface-alt"></div>
@ -277,29 +373,39 @@
{% endfor %}
</div>
<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>
{% endif %}
</section>
</section>
<!-- Data Cards -->
<section>
<!-- Data Cards -->
<section>
<div class="flex items-center justify-between mb-5">
<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
>
</div>
{% if !stats.is_empty() %}
<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"
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") }}
</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
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"
@ -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"
>
{{ 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>
</a>
<a
@ -349,18 +457,26 @@
<span class="text-sm font-medium text-accent">Cafes</span>
</a>
</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"
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") }}
</button>
</chip-scroll>
{% else %}
<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 %}
<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-5 w-8 rounded bg-surface-alt"></div>
<div class="h-4 w-16 rounded bg-surface-alt"></div>
@ -372,5 +488,5 @@
</div>
</div>
{% endif %}
</section>
</section>
{% endblock %}

View file

@ -1,38 +1,49 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Login{% endblock %}
{% block head %}
<script src="/static/js/webauthn.js"></script>
<script src="/static/js/webauthn.js"></script>
{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
<div class="mx-auto max-w-md">
<div class="rounded-lg border bg-surface p-6">
<h1 class="text-2xl font-semibold text-accent">Login</h1>
<p class="mt-2 text-sm text-text-secondary">
Sign in with a passkey to access the coffee log.
</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">
This browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
<div
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 class="mt-6">
<button
id="login-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
</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>
</div>
</div>
</div>
</div>
</div>
<script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const button = document.getElementById("login-button");
const errorDiv = document.getElementById("login-error");
@ -74,5 +85,5 @@
}
});
});
</script>
</script>
{% endblock %}

View file

@ -1,20 +1,28 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Register{% endblock %}
{% block head %}
<script src="/static/js/webauthn.js"></script>
<script src="/static/js/webauthn.js"></script>
{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
<div class="mx-auto max-w-md">
<div class="rounded-lg border bg-surface p-6">
<h1 class="text-2xl font-semibold text-accent">Register</h1>
<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>
<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">
This browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
<div
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 id="register-form" class="mt-6 flex flex-col gap-4">
@ -44,19 +52,22 @@
<button
id="register-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
</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>
</div>
</div>
</div>
</div>
</div>
<script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const token = "{{ token }}";
const button = document.getElementById("register-button");
@ -103,5 +114,5 @@
}
});
});
</script>
</script>
{% endblock %}

View file

@ -3,34 +3,42 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ roast.name }}{% endblock %}
{% block og_title %}{{ roast.name }} — Brewlog{% endblock %}
{% block og_description %}{{ roast.name }} by {{ roast.roaster_name }} — {{ roast.origin }}{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
{% block og_description %}
{{ 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 %}
<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">
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
<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>
</div>
</header>
</header>
<div class="grid gap-6 md:grid-cols-2">
<div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes) }}
{{ detail::map_with_legend_2(roast.map_countries, roast.map_max, "Origin", "", "Roaster", "opacity-50") }}
</div>
</div>
<div class="grid gap-6 md:grid-cols-2">
<div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(roast.roaster_name, roast.roaster_country, roast.roaster_country_flag, roast.roaster_city, roast.roaster_homepage) }}
</div>
</div>
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button"
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<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"
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
</button>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -3,26 +3,34 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ roaster.name }}{% endblock %}
{% block og_title %}{{ roaster.name }} — Brewlog{% endblock %}
{% block og_description %}{{ roaster.name }} — {{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %}, {{ c }}{% endif %}{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
{% block og_description %}
{{ 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 %}
<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">
<h1 class="text-3xl font-semibold">{{ roaster.name }}</h1>
<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>
</div>
</header>
</header>
<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">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text">
{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}
{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }}
</dd>
</div>
{% if let Some(c) = roaster.city %}
@ -35,7 +43,13 @@
<div>
<dt class="text-text-muted">Website</dt>
<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>
</div>
{% endif %}
@ -43,15 +57,17 @@
</div>
{{ detail::map_with_legend_1(roaster.map_countries, roaster.map_max, "Roaster", "") }}
</div>
</div>
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button"
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<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"
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
</button>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -1,45 +1,65 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/histogram.html" as histogram %} {% block title %}Brewlog · Stats{% endblock %}
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% import "partials/histogram.html" as histogram %}
{% block title %}Brewlog · Stats{% endblock %}
{% block og_title %}Stats — Brewlog{% endblock %}
{% block og_description %}Aggregated coffee data across origins, consumption, and brewing.{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
{% block og_description %}
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 %}
<header class="flex flex-col gap-2">
<header class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-4">
<h1 class="text-3xl font-semibold">Stats</h1>
{% 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 %}
</div>
<p class="max-w-2xl text-sm text-text-secondary">Aggregated coffee data across origins, consumption, and brewing.</p>
</header>
<p class="max-w-2xl text-sm text-text-secondary">
Aggregated coffee data across origins, consumption, and brewing.
</p>
</header>
{% if has_data %}
<div class="flex flex-col gap-6">
{% if has_data %}
<div class="flex flex-col gap-6">
{% include "partials/tab_bar.html" %}
<div id="stats-content">
{{ content|safe }}
<div id="stats-content">{{ content|safe }}</div>
</div>
</div>
<section>
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Roast Stats</h2>
</div>
<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") }}
<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>
</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") }}
<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>
</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") }}
<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>
</div>
</div>
@ -47,49 +67,69 @@
<div class="mt-5 grid gap-5 md:grid-cols-2">
{% if !roast_summary.origin_counts.is_empty() %}
<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) }}
</div>
{% endif %}
{% if !roast_summary.flavour_counts.is_empty() %}
<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) }}
</div>
{% endif %}
</div>
{% endif %}
</section>
</section>
<section>
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Consumption</h2>
</div>
<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") }}
<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>
</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") }}
<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>
</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") }}
<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>
</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") }}
<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>
</div>
</div>
</section>
</section>
<section>
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Brewing</h2>
</div>
@ -98,13 +138,21 @@
{% if !brewing_summary.brewer_counts.is_empty() %}
<div class="rounded-lg border bg-surface p-5">
<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>
{% endif %}
{% if !brewing_summary.grinder_counts.is_empty() %}
<div class="rounded-lg border bg-surface p-5">
<h3 class="text-sm font-semibold text-text mb-4">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>
<h3 class="text-sm font-semibold text-text mb-4">
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>
{% endif %}
</div>
@ -112,27 +160,38 @@
<div class="mt-5 grid gap-5 md:grid-cols-2">
{% if !grinder_weights.is_empty() %}
<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) }}
</div>
{% endif %}
{% if !brewing_summary.brew_time_distribution.is_empty() %}
<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) }}
</div>
{% endif %}
</div>
</section>
{% else %}
<div class="relative">
<div class="flex flex-col gap-8 blur-[2px] select-none pointer-events-none" aria-hidden="true">
</section>
{% else %}
<div class="relative">
<div
class="flex flex-col gap-8 blur-[2px] select-none pointer-events-none"
aria-hidden="true"
>
<!-- Placeholder: Roast Stats -->
<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">
{% 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-5 w-10 rounded bg-surface-alt"></div>
<div class="h-4 w-16 rounded bg-surface-alt"></div>
@ -142,10 +201,14 @@
</section>
<!-- Placeholder: Consumption -->
<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">
{% 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-5 w-12 rounded bg-surface-alt"></div>
<div class="h-4 w-20 rounded bg-surface-alt"></div>
@ -155,7 +218,9 @@
</section>
<!-- Placeholder: Brewing charts -->
<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">
{% for _ in 0..2 %}
<div class="rounded-lg border bg-surface p-5">
@ -177,6 +242,6 @@
<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>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

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

View file

@ -1,27 +1,41 @@
{% import "partials/icons.html" as icons %}
{% 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">
<span class="block font-semibold text-text truncate">{{ bag.roast_name }}</span>
<p class="mt-1 text-sm text-text-muted truncate">{{ bag.roaster_name }}</p>
<span class="block font-semibold text-text truncate"
>{{ bag.roast_name }}</span
>
<p class="mt-1 text-sm text-text-muted truncate">
{{ bag.roaster_name }}
</p>
</div>
<div class="flex flex-col items-center">
<div class="w-full">
<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>
<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>
{% if is_authenticated %}
<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"
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
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
</span>
</div>
{% endif %}
</a>
</a>
{% endmacro %}

View file

@ -1,25 +1,43 @@
{% import "partials/icons.html" as icons %}
{% 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">
<span class="block font-semibold text-text truncate">{{ brew.roast_name }}</span>
<p class="mt-0.5 text-xs text-text-muted">{{ brew.relative_date_label }}</p>
<span class="block font-semibold text-text truncate"
>{{ brew.roast_name }}</span
>
<p class="mt-0.5 text-xs text-text-muted">
{{ brew.relative_date_label }}
</p>
</div>
<div 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>
<div
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.brewer_name }}</p>
{% 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>
{% if is_authenticated %}
<div class="relative z-10 mt-3">
<span onclick="event.preventDefault(); window.location.href='{{ brew.brew_again_url() }}';"
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 Again
<span
onclick="event.preventDefault(); window.location.href='{{ brew.brew_again_url() }}';"
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 Again
</span>
</div>
{% endif %}
</a>
</a>
{% endmacro %}

View file

@ -1,32 +1,37 @@
{% import "partials/icons.html" as icons %}
{% 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-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>
</button>
</button>
{% endmacro %}
{% macro share_script() %}
<script>
const shareLink = (btn) => {
<script>
const shareLink = (btn) => {
navigator.clipboard.writeText(window.location.href).then(() => {
btn.querySelector('.share-icon').classList.add('hidden');
btn.querySelector('.share-icon-check').classList.remove('hidden');
btn.querySelector('.share-label').textContent = 'Copied';
btn.querySelector(".share-icon").classList.add("hidden");
btn.querySelector(".share-icon-check").classList.remove("hidden");
btn.querySelector(".share-label").textContent = "Copied";
setTimeout(() => {
btn.querySelector('.share-icon').classList.remove('hidden');
btn.querySelector('.share-icon-check').classList.add('hidden');
btn.querySelector('.share-label').textContent = 'Share';
btn.querySelector(".share-icon").classList.remove("hidden");
btn.querySelector(".share-icon-check").classList.add("hidden");
btn.querySelector(".share-label").textContent = "Share";
}, 2000);
});
};
</script>
};
</script>
{% endmacro %}
{% macro coffee_card(roast_name, roaster_name, origin, origin_flag, region, producer, process, tasting_notes) %}
<div class="rounded-lg border bg-surface p-5">
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Coffee</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
@ -41,7 +46,7 @@ const shareLink = (btn) => {
<div>
<dt class="text-text-muted">Origin</dt>
<dd class="font-medium text-text">
{% if !origin_flag.is_empty() %}{{ origin_flag }} {% endif %}{{ origin }}
{% if !origin_flag.is_empty() %}{{ origin_flag }}{% endif %}{{ origin }}
</dd>
</div>
{% endif %}
@ -71,71 +76,101 @@ const shareLink = (btn) => {
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro map_with_legend_1(map_countries, map_max, label1, opacity1) %}
{% if !map_countries.is_empty() %}
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
{% if !map_countries.is_empty() %}
<div
class="relative rounded-lg border bg-surface overflow-hidden flex items-center"
>
<world-map
class="block w-full"
data-countries="{{ map_countries }}"
data-max="{{ map_max }}"
></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-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>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endmacro %}
{% macro map_with_legend_2(map_countries, map_max, label1, opacity1, label2, opacity2) %}
{% if !map_countries.is_empty() %}
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
{% if !map_countries.is_empty() %}
<div
class="relative rounded-lg border bg-surface overflow-hidden flex items-center"
>
<world-map
class="block w-full"
data-countries="{{ map_countries }}"
data-max="{{ map_max }}"
></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-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 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>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endmacro %}
{% macro map_with_legend_3(map_countries, map_max, label1, opacity1, label2, opacity2, label3, opacity3) %}
{% if !map_countries.is_empty() %}
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
{% if !map_countries.is_empty() %}
<div
class="relative rounded-lg border bg-surface overflow-hidden flex items-center"
>
<world-map
class="block w-full"
data-countries="{{ map_countries }}"
data-max="{{ map_max }}"
></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-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 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 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>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endmacro %}
{% macro roaster_card(name, country, country_flag, city, homepage) %}
<div class="rounded-lg border bg-surface p-5">
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Roaster</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
@ -145,7 +180,7 @@ const shareLink = (btn) => {
<div>
<dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text">
{% if !country_flag.is_empty() %}{{ country_flag }} {% endif %}{{ country }}
{% if !country_flag.is_empty() %}{{ country_flag }}{% endif %}{{ country }}
</dd>
</div>
{% if let Some(c) = city %}
@ -158,10 +193,16 @@ const shareLink = (btn) => {
<div>
<dt class="text-text-muted">Website</dt>
<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>
</div>
{% endif %}
</dl>
</div>
</div>
{% endmacro %}

View file

@ -1,116 +1,230 @@
<!-- Hidden inputs for submission (always present, bound to signals) -->
<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_country" data-attr:value="$_roasterCountry" />
<input type="hidden" name="roaster_city" data-attr:value="$_roasterCity" />
<input type="hidden" name="roaster_homepage" data-attr:value="$_roasterHomepage" />
<input type="hidden" name="roast_name" data-attr:value="$_roastName" />
<input type="hidden" name="origin" data-attr:value="$_origin" />
<input type="hidden" name="region" data-attr:value="$_region" />
<input type="hidden" name="producer" data-attr:value="$_producer" />
<input type="hidden" name="process" data-attr:value="$_process" />
<input type="hidden" name="tasting_notes" data-attr:value="$_tastingNotes" />
<!-- Hidden inputs for submission (always present, bound to signals) -->
<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_country"
data-attr:value="$_roasterCountry"
/>
<input type="hidden" name="roaster_city" data-attr:value="$_roasterCity" />
<input
type="hidden"
name="roaster_homepage"
data-attr:value="$_roasterHomepage"
/>
<input type="hidden" name="roast_name" data-attr:value="$_roastName" />
<input type="hidden" name="origin" data-attr:value="$_origin" />
<input type="hidden" name="region" data-attr:value="$_region" />
<input type="hidden" name="producer" data-attr:value="$_producer" />
<input type="hidden" name="process" data-attr:value="$_process" />
<input type="hidden" name="tasting_notes" data-attr:value="$_tastingNotes" />
<!-- Roaster: card (matched) -->
<div data-show="$_matchedRoasterId">
<!-- Roaster: card (matched) -->
<div data-show="$_matchedRoasterId">
<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">
{{ icons::fire("h-4 w-4 text-accent shrink-0") }}
<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>
<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>
<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>
<!-- Roaster: form (not matched) -->
<div data-show="!$_matchedRoasterId" style="display:none">
</div>
<!-- Roaster: form (not matched) -->
<div data-show="!$_matchedRoasterId" style="display:none">
<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">
<label class="flex flex-col gap-1 text-sm">
<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 class="flex flex-col gap-1 text-sm">
<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 class="flex flex-col gap-1 text-sm">
<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 class="flex flex-col gap-1 text-sm">
<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>
</div>
</div>
</div>
<!-- Roast: card (matched) -->
<div data-show="$_matchedRoastId">
<!-- Roast: card (matched) -->
<div data-show="$_matchedRoastId">
<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">
{{ icons::coffee_bean("h-4 w-4 text-accent shrink-0") }}
<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>
<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>
<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>
<!-- Roast: form (not matched) -->
<div data-show="!$_matchedRoastId" style="display:none">
</div>
<!-- Roast: form (not matched) -->
<div data-show="!$_matchedRoastId" style="display:none">
<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">
<label class="flex flex-col gap-1 text-sm">
<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 class="flex flex-col gap-1 text-sm">
<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 class="flex flex-col gap-1 text-sm">
<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 class="flex flex-col gap-1 text-sm">
<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 class="flex flex-col gap-1 text-sm">
<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 class="flex flex-col gap-1 text-sm">
<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>
</div>
</div>
</div>
<div>
<div>
<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>
</label>
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<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>
</div>
</div>
<p data-show="$_scanError" data-text="$_scanError" 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 data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
</div>
<p
data-show="$_scanError"
data-text="$_scanError"
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 data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
<button
type="button"
data-on:click="$_scanExtracted = false; $_matchedRoasterId = ''; $_matchedRoastId = ''"
@ -130,7 +244,8 @@
<button
type="submit"
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
</button>
@ -138,13 +253,18 @@
<button
type="submit"
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
</button>
<!-- Both matched + no bag: nothing to do -->
<p data-show="$_matchedRoastId && !$_openBag" 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
data-show="$_matchedRoastId && !$_openBag"
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>
</div>
</div>

View file

@ -1,31 +1,47 @@
{% macro bar_chart(items, max_count) %}
<div class="rounded-lg border bg-surface p-5">
<div class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-2">
{% for item in items %}
<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="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>
<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>
{% endfor %}
</div>
</div>
</div>
{% endmacro %}
{% macro bar_chart_weight(items, max_weight) %}
<div class="rounded-lg border bg-surface p-5">
<div class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-2">
{% for item in items %}
<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="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>
<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>
{% endfor %}
</div>
</div>
</div>
{% endmacro %}

View file

@ -1,218 +1,525 @@
{% macro plus(class) %}
<svg 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
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>
{% endmacro %}
{% macro camera(class) %}
<svg 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
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>
{% endmacro %}
{% 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">
<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
class="{{ class }} animate-spin text-accent"
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>
{% endmacro %}
{% macro delete(class) %}
<svg 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
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>
{% endmacro %}
{% macro external_link(class) %}
<svg class="{{ class }}" 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
class="{{ class }}"
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>
{% endmacro %}
{% macro location(class) %}
<svg 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
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>
{% endmacro %}
{% macro map(class) %}
<svg 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
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>
{% endmacro %}
{% macro refresh(class) %}
<svg 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
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>
{% endmacro %}
{% macro bag(class) %}
<svg 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
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>
{% endmacro %}
{% macro cup(class) %}
<svg class="{{ class }}" 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
class="{{ class }}"
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>
{% endmacro %}
{% macro arrow_up(class) %}
<svg 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
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>
{% endmacro %}
{% macro arrow_down_tray(class) %}
<svg class="{{ class }}" 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
class="{{ class }}"
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>
{% endmacro %}
{% macro arrow_up_tray(class) %}
<svg class="{{ class }}" 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
class="{{ class }}"
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>
{% endmacro %}
{% macro plus_circle(class) %}
<svg 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
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>
{% endmacro %}
{% macro logout(class) %}
<svg 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
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>
{% endmacro %}
{% macro beaker(class) %}
<svg 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
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>
{% endmacro %}
{% macro coffee_bean(class) %}
<svg class="{{ class }}" 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
class="{{ class }}"
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>
{% endmacro %}
{% macro fire(class) %}
<svg 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
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>
{% endmacro %}
{% 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" />
<rect x="5" y="16.5" width="10" height="1.5" rx=".5" />
</svg>
</svg>
{% endmacro %}
{% macro ellipsis_vertical(class) %}
<svg 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
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>
{% endmacro %}
{% macro x_circle(class) %}
<svg 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
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>
{% endmacro %}
{% macro chevron_down(class) %}
<svg 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
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>
{% endmacro %}
{% macro chevron_up(class) %}
<svg 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
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>
{% endmacro %}
{% macro user(class) %}
<svg 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
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>
{% endmacro %}
{% macro sun(class) %}
<svg 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
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>
{% endmacro %}
{% macro moon(class) %}
<svg 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
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>
{% endmacro %}
{% macro check_circle(class) %}
<svg 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
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>
{% endmacro %}
{% macro key(class) %}
<svg 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
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>
{% endmacro %}
{% macro terminal(class) %}
<svg 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
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>
{% endmacro %}
{% macro pencil(class) %}
<svg 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
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>
{% endmacro %}
{% macro clipboard(class) %}
<svg 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
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>
{% endmacro %}
{% macro check(class) %}
<svg 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
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>
{% endmacro %}
{% macro x_mark(class) %}
<svg 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
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>
{% endmacro %}
{% macro chevron_left(class) %}
<svg 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
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>
{% endmacro %}
{% macro chevron_right(class) %}
<svg 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
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>
{% endmacro %}

View file

@ -15,13 +15,22 @@
</p>
</div>
{% else %}
<section 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 %}
<section
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") }}
<div class="overflow-x-auto">
<table 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">
<table
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>
{{ table::sortable_header("Added", "created-at", navigator, "#bag-list") }}
{{ table::sortable_header("Roaster", "roaster", navigator, "#bag-list") }}
@ -33,44 +42,81 @@
</thead>
<tbody class="divide-y/70">
{% 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 }}'"
>
<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 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 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 }}
</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 }}
</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 %}
<span class="text-text-muted">Closed</span>
{% else %}
<div>
<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
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 class="mt-0.5 text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}</div>
</div>
{% endif %}
</td>
{% if !bag.closed %}
<td data-label="" class="card-progress md:hidden">
<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
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 class="mt-0.5 text-right text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}</div>
</td>
{% 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>
</td>
{% 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>
</td>
{% endif %}
@ -81,8 +127,10 @@
<span class="text-text-muted">Closed</span>
{% endif %}
</span>
<a 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">
<a
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") }}
</a>
</div>
@ -93,11 +141,16 @@
</table>
</div>
{% 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 %}
{{ table::pagination_header(bags, navigator, "#bag-list") }}
{% 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 %}
</section>
{% endif %}

View file

@ -15,14 +15,23 @@
</p>
</div>
{% else %}
<section 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 %}
<section
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") }}
<div class="overflow-x-auto">
<table 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">
<table
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>
{{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }}
<th scope="col" class="px-4 py-3">Coffee</th>
@ -35,39 +44,83 @@
</thead>
<tbody class="divide-y/70">
{% 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 }}'"
>
<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 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 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="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 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">
<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 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">
<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>
{% 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 %}
<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">
<div>{{ brew.brewer_name }}</div>
{% 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 %}
</td>
{% 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 %}
<td data-label="Notes" class="px-4 py-3">
{% if !brew.quick_notes.is_empty() %}
@ -81,8 +134,10 @@
{% endif %}
</td>
<td data-label="" class="card-actions px-4 py-3 text-right">
<a 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">
<a
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") }}
</a>
</td>
@ -101,7 +156,10 @@
{{ table::pagination_header(brews, navigator, "#brew-list") }}
{% 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 %}
</section>
{% endif %}

View file

@ -15,13 +15,22 @@
</p>
</div>
{% else %}
<section 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 %}
<section
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") }}
<div class="overflow-x-auto">
<table 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">
<table
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>
{{ table::sortable_header("Added", "created-at", navigator, "#cafe-list") }}
{{ table::sortable_header("Name", "name", navigator, "#cafe-list") }}
@ -41,20 +50,52 @@
class="transition hover:bg-surface-alt"
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 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 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 }}
</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 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="Location"
class="px-4 py-3 whitespace-nowrap md:hidden"
>
{% 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">
<a 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">
<a
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") }}
</a>
</td>
@ -64,11 +105,16 @@
</table>
</div>
{% 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 %}
{{ table::pagination_header(cafes, navigator, "#cafe-list") }}
{% 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 %}
</section>
{% endif %}

View file

@ -15,13 +15,22 @@
</p>
</div>
{% else %}
<section 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 %}
<section
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") }}
<div class="overflow-x-auto">
<table 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">
<table
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>
{{ table::sortable_header("Added", "created-at", navigator, "#cup-list") }}
{{ table::sortable_header("Roast", "roast", navigator, "#cup-list") }}
@ -38,24 +47,61 @@
class="transition hover:bg-surface-alt"
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 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 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>
</td>
<td data-label="Roaster" 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="Roaster"
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">
{{ cup.cafe_name }}
</td>
<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="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">
<a 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">
<a
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") }}
</a>
</td>
@ -65,11 +111,16 @@
</table>
</div>
{% 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 %}
{{ table::pagination_header(cups, navigator, "#cup-list") }}
{% 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 %}
</section>
{% endif %}

View file

@ -15,14 +15,23 @@
</p>
</div>
{% else %}
<section 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 %}
<section
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") }}
<div class="overflow-x-auto">
<table 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">
<table
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>
{{ table::sortable_header("Added", "created-at", navigator, "#gear-list") }}
{{ table::sortable_header("Category", "category", navigator, "#gear-list") }}
@ -33,26 +42,47 @@
</thead>
<tbody class="divide-y/70">
{% 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 }}'"
>
<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 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 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">
{{ item.category_label }}
</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 }}
</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 }}
</td>
<td data-label="" class="card-actions px-4 py-3 text-right">
<a 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">
<a
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") }}
</a>
</td>
@ -62,11 +92,16 @@
</table>
</div>
{% 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 %}
{{ table::pagination_header(gear, navigator, "#gear-list") }}
{% 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 %}
</section>
{% endif %}

View file

@ -15,13 +15,22 @@
</p>
</div>
{% else %}
<section 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 %}
<section
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") }}
<div class="overflow-x-auto">
<table 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">
<table
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>
{{ table::sortable_header("Added", "created-at", navigator, "#roast-list") }}
{{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
@ -42,29 +51,54 @@
class="transition hover:bg-surface-alt"
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 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 data-label="Roast" class="card-title px-4 py-3">
<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 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">
{% 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() %}
<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 %}
</td>
{% 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 %}
<td data-label="Tasting Notes" class="px-4 py-3">
{% 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 %}
<span class="{{ note.pill_class }}">{{ note.label }}</span>
<span class="{{ note.pill_class }}"
>{{ note.label }}</span
>
{% endfor %}
</div>
{% else %}
@ -72,8 +106,10 @@
{% endif %}
</td>
<td data-label="" class="card-actions px-4 py-3 text-right">
<a 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">
<a
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") }}
</a>
</td>
@ -83,11 +119,16 @@
</table>
</div>
{% 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 %}
{{ table::pagination_header(roasts, navigator, "#roast-list") }}
{% 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 %}
</section>
{% endif %}

View file

@ -15,13 +15,22 @@
</p>
</div>
{% else %}
<section 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 %}
<section
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") }}
<div class="overflow-x-auto">
<table 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">
<table
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>
{{ table::sortable_header("Added", "created-at", navigator, "#roaster-list") }}
{{ table::sortable_header("Name", "name", navigator, "#roaster-list") }}
@ -41,22 +50,54 @@
class="transition hover:bg-surface-alt"
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 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 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 }}
</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() %}
<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 %}
<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="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">
<a 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">
<a
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") }}
</a>
</td>
@ -66,11 +107,16 @@
</table>
</div>
{% 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 %}
{{ table::pagination_header(roasters, navigator, "#roaster-list") }}
{% 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 %}
</section>
{% endif %}

View file

@ -7,7 +7,9 @@
class="input-field w-full text-sm"
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'}})"
{% 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>
{% 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"
>
<span>
Showing {{ items.start_index() }}&ndash;{{ items.end_index() }} of {{ items.total }}
results
Showing {{ items.start_index() }}&ndash;{{ items.end_index() }} of
{{ items.total }} results
</span>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
@ -26,7 +28,11 @@
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"
{% 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 %}
disabled
{% endif %}
@ -45,7 +51,11 @@
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"
{% 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 %}
disabled
{% endif %}
@ -54,17 +64,54 @@
<span aria-hidden="true"></span>
</button>
</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>
<select
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'}})"
>
<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 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>
<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
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>
</label>
</div>

View file

@ -1,42 +1,29 @@
{% import "partials/icons.html" as icons %}
{% macro location_search_js() %}
const locateUser = (triggerEl) => {
const root = triggerEl.closest('[data-location-root]');
const emit = (name, detail) =>
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
if (!navigator.geolocation) {
emit('location-error', { message: 'Geolocation not supported by this browser.' });
return;
}
emit('location-start');
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 },
);
};
const locateUser = (triggerEl) => { const root =
triggerEl.closest('[data-location-root]'); const emit = (name, detail) =>
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); if
(!navigator.geolocation) { emit('location-error', { message: 'Geolocation not
supported by this browser.' }); return; } emit('location-start');
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 %}
{% 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-error="$_locating = false; {{ error_signal }} = evt.detail.message"
data-on:location-start="$_locating = true"
>
>
<div class="flex items-center gap-2 mb-3">
<button type="button"
<button
type="button"
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"
data-class="{'text-accent': $_locationFound, 'text-text': !$_locationFound}"
@ -44,20 +31,29 @@ const locateUser = (triggerEl) => {
aria-label="Use my location"
>
<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>
<input type="text" data-bind:_city-name
<input
type="text"
data-bind:_city-name
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: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'}})"
/>
</div>
<input type="text" data-bind:_cafe-search
<input
type="text"
data-bind:_cafe-search
class="input-field w-full"
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'}}) }"
/>
<div id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"></div>
</div>
<div
id="nearby-results"
class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"
></div>
</div>
{% endmacro %}

View file

@ -1,23 +1,50 @@
{% import "partials/icons.html" as icons %}
<nav class="border-b pb-4 text-sm">
<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">
{% 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") }}
</a>
{% 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-dark hidden">{{ icons::sun("h-5 w-5") }}</span>
</button>
{% 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") }}
</a>
{% 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") }}
</a>
{% endif %}
@ -43,7 +70,8 @@
};
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) => {
el.classList.toggle("hidden", isDark);
});
@ -52,7 +80,9 @@
});
const favicon = document.getElementById("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() %}
<p class="px-3 py-2 text-sm text-text-muted">No nearby cafes found.</p>
{% 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 %}
<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"
>
<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>
{% endfor %}
{% endif %}

View file

@ -1,9 +1,11 @@
{% import "partials/icons.html" as icons %}
{% macro scan_input(form_id, image_input_id, extracting_signal, error_signal, placeholder, spinner_text) %}
<input type="hidden" name="image" id="{{ image_input_id }}" />
<div data-show="!{{ extracting_signal }}" class="flex items-center gap-2">
<brew-photo-capture target-input="{{ image_input_id }}" target-form="{{ form_id }}"
<input type="hidden" name="image" id="{{ image_input_id }}" />
<div data-show="!{{ extracting_signal }}" class="flex items-center gap-2">
<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"
aria-label="Take Photo"
>
@ -16,10 +18,19 @@
class="input-field w-full"
placeholder="{{ placeholder }}"
/>
</div>
<div data-show="{{ extracting_signal }}" style="display:none" class="flex items-center gap-3 text-sm text-accent">
</div>
<div
data-show="{{ extracting_signal }}"
style="display:none"
class="flex items-center gap-3 text-sm text-accent"
>
{{ icons::spinner("h-5 w-5") }}
{{ spinner_text }}
</div>
<p data-show="{{ error_signal }}" data-text="{{ error_signal }}" style="display:none" class="mt-2 text-sm text-red-600"></p>
</div>
<p
data-show="{{ error_signal }}"
data-text="{{ error_signal }}"
style="display:none"
class="mt-2 text-sm text-red-600"
></p>
{% endmacro %}

View file

@ -1,7 +1,9 @@
{% import "partials/icons.html" as icons %}
<div class="flex flex-col gap-4">
{% 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>
</div>
{% else %}
@ -14,41 +16,63 @@
</div>
<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"
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") }}
</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 %}
{% 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"
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">
{% if !entry.flag_emoji.is_empty() %}
<span>{{ entry.flag_emoji }}</span>
{% 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 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>
{% 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="text-text whitespace-nowrap">{{ entry.country_name }}</span>
<span class="text-text whitespace-nowrap"
>{{ entry.country_name }}</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>
{% endif %}
{% endfor %}
</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"
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") }}
</button>
</chip-scroll>

View file

@ -1,6 +1,9 @@
{% 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 -->
<nav class="hidden md:flex gap-1.5" role="tablist">
{% for tab in tabs %}
@ -9,8 +12,15 @@
role="tab"
class="tab"
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 %}"
>{% 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>
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 %}"
>
{% 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 %}
</nav>
<!-- Mobile tab selector -->
@ -22,20 +32,38 @@
>
<span>
{% 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 %}
</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>
<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 %}
<button
type="button"
class="tab-mobile"
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 %}"
>{% 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>
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 %}"
>
{% 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 %}
</div>
</div>

View file

@ -4,6 +4,6 @@
data-has-more="{{ events.has_next() }}"
>
<div data-chunk-months>
{% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
{% for month in months %}{% include "partials/timeline_month.html" %}{% endfor %}
</div>
</div>

View file

@ -1,7 +1,16 @@
{% 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 }}
<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") }}
</button>
<button
@ -9,12 +18,18 @@
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 = '' }"
>
<span data-show="!$_expandedMonths.includes(',{{ month.anchor }}')">Expand all</span>
<span data-show="$_expandedMonths.includes(',{{ month.anchor }}')" style="display:none">Collapse all</span>
<span data-show="!$_expandedMonths.includes(',{{ month.anchor }}')"
>Expand all</span
>
<span
data-show="$_expandedMonths.includes(',{{ month.anchor }}')"
style="display:none"
>Collapse all</span
>
</button>
</h2>
{% for event in month.events %}
<div class="timeline-item relative mb-8" data-timeline-event>
<div class="timeline-item relative mb-8" data-timeline-event>
{# Timeline node/bullet #}
<span
class="timeline-node absolute h-5 w-5 rounded-full border-[3px] border-accent bg-page"
@ -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 %}
<span class="uppercase tracking-wide">{{ event.kind_label }}</span>
{# 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 #}
<time
datetime="{{ event.iso_timestamp }}"
class="tl-detail uppercase tracking-wide"
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
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>
{# 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") }}
</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") }}
</span>
</div>
<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 #}
{% if let Some(url) = event.external_link %}
<a
@ -70,7 +104,9 @@
<p
class="tl-condensed mt-1 text-sm text-text-muted"
data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"
>{{ sub }}</p>
>
{{ sub }}
</p>
{% endif %}
{# Detail rows — shown when expanded #}
{% if event.details.len() > 0 %}
@ -85,12 +121,26 @@
{% if detail.label.eq_ignore_ascii_case("position") && detail.link.is_some() %}
<dd class="text-right flex items-center justify-end gap-1.5">
{{ 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") }}
</a>
</dd>
{% 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 %}
<dd class="text-right">{{ detail.value }}</dd>
{% endif %}
@ -99,7 +149,9 @@
{% if let Some(notes) = event.tasting_notes %}
{% if !notes.is_empty() %}
<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">
{% for note in notes %}
<span class="{{ note.pill_class }}">{{ note.label }}</span>
@ -111,5 +163,5 @@
</dl>
{% endif %}
</div>
</div>
</div>
{% endfor %}