chore: autoformat templates with prettier
This commit is contained in:
parent
8d433d8e99
commit
1a71fb4158
49 changed files with 6736 additions and 4430 deletions
39
CLAUDE.md
39
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,27 +12,27 @@
|
|||
color-scheme: light;
|
||||
|
||||
/* Surfaces */
|
||||
--page: #fafaf9; /* stone-50 */
|
||||
--surface: #ffffff; /* white */
|
||||
--surface-alt: #f5f5f4; /* stone-100 */
|
||||
--page: #fafaf9; /* stone-50 */
|
||||
--surface: #ffffff; /* white */
|
||||
--surface-alt: #f5f5f4; /* stone-100 */
|
||||
|
||||
/* Borders */
|
||||
--border: #e7e5e4; /* stone-200 */
|
||||
--border-muted: #f5f5f4; /* stone-100 */
|
||||
--border: #e7e5e4; /* stone-200 */
|
||||
--border-muted: #f5f5f4; /* stone-100 */
|
||||
|
||||
/* Accent */
|
||||
--accent: #c2410c; /* orange-700 */
|
||||
--accent-hover: #ea580c; /* orange-600 */
|
||||
--accent-subtle: #fff7ed; /* orange-50 */
|
||||
--accent: #c2410c; /* orange-700 */
|
||||
--accent-hover: #ea580c; /* orange-600 */
|
||||
--accent-subtle: #fff7ed; /* orange-50 */
|
||||
--accent-text: #ffffff;
|
||||
|
||||
/* Text */
|
||||
--text: #1c1917; /* stone-900 */
|
||||
--text-secondary: #57534e; /* stone-600 */
|
||||
--text-muted: #78716c; /* stone-500 */
|
||||
--text: #1c1917; /* stone-900 */
|
||||
--text-secondary: #57534e; /* stone-600 */
|
||||
--text-muted: #78716c; /* stone-500 */
|
||||
|
||||
/* Data-viz highlight (RGB triplet for alpha usage in JS) */
|
||||
--highlight-rgb: 194, 65, 12; /* orange-700, matches --accent */
|
||||
--highlight-rgb: 194, 65, 12; /* orange-700, matches --accent */
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
--text-secondary: #d6d3d1;
|
||||
--text-muted: #a8a29e;
|
||||
|
||||
--highlight-rgb: 234, 88, 12; /* orange-600, matches --accent */
|
||||
--highlight-rgb: 234, 88, 12; /* orange-600, matches --accent */
|
||||
}
|
||||
|
||||
/* ── Theme: map raw vars to Tailwind utilities ─────────────────── */
|
||||
|
|
@ -87,7 +87,13 @@
|
|||
/* ── Typography ────────────────────────────────────────────────── */
|
||||
|
||||
body {
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
|
|
@ -125,7 +131,9 @@ a {
|
|||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
input.input-field[type="number"]::-webkit-inner-spin-button,
|
||||
|
|
@ -155,7 +163,9 @@ input.input-field[type="number"] {
|
|||
background-color: var(--surface);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.btn-adjust:hover {
|
||||
|
|
@ -343,7 +353,10 @@ input.input-field[type="number"] {
|
|||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--surface);
|
||||
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +381,9 @@ input.input-field[type="number"] {
|
|||
border: none;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
|
@ -452,7 +467,9 @@ input.input-field[type="number"] {
|
|||
/* ── Data page ─────────────────────────────────────────────────── */
|
||||
|
||||
/* Hide duplicate search inside data page - search is in the tab bar */
|
||||
.data-page-content section:has(.responsive-table) > div:first-child:has(input[type="search"]) {
|
||||
.data-page-content
|
||||
section:has(.responsive-table)
|
||||
> div:first-child:has(input[type="search"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,38 @@
|
|||
customElements.define("chip-scroll", class extends HTMLElement {
|
||||
connectedCallback() { this._setup(); }
|
||||
customElements.define(
|
||||
"chip-scroll",
|
||||
class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this._setup();
|
||||
}
|
||||
|
||||
_setup() {
|
||||
if (this._observer) this._observer.disconnect();
|
||||
_setup() {
|
||||
if (this._observer) this._observer.disconnect();
|
||||
|
||||
const scroller = this.querySelector("[data-chip-scroll]");
|
||||
const btnL = this.querySelector("[data-scroll-left]");
|
||||
const btnR = this.querySelector("[data-scroll-right]");
|
||||
if (!scroller || !btnL || !btnR) return;
|
||||
const scroller = this.querySelector("[data-chip-scroll]");
|
||||
const btnL = this.querySelector("[data-scroll-left]");
|
||||
const btnR = this.querySelector("[data-scroll-right]");
|
||||
if (!scroller || !btnL || !btnR) return;
|
||||
|
||||
const update = () => {
|
||||
if (window.matchMedia("(max-width: 767px)").matches) {
|
||||
btnL.style.display = "none";
|
||||
btnR.style.display = "none";
|
||||
return;
|
||||
}
|
||||
btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none";
|
||||
btnR.style.display = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1 ? "flex" : "none";
|
||||
};
|
||||
const update = () => {
|
||||
if (window.matchMedia("(max-width: 767px)").matches) {
|
||||
btnL.style.display = "none";
|
||||
btnR.style.display = "none";
|
||||
return;
|
||||
}
|
||||
btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none";
|
||||
btnR.style.display =
|
||||
scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1
|
||||
? "flex"
|
||||
: "none";
|
||||
};
|
||||
|
||||
scroller.addEventListener("scroll", update, { passive: true });
|
||||
new ResizeObserver(update).observe(scroller);
|
||||
scroller.addEventListener("scroll", update, { passive: true });
|
||||
new ResizeObserver(update).observe(scroller);
|
||||
|
||||
this._observer = new MutationObserver(update);
|
||||
this._observer.observe(scroller, { childList: true });
|
||||
this._observer = new MutationObserver(update);
|
||||
this._observer.observe(scroller, { childList: true });
|
||||
|
||||
update();
|
||||
}
|
||||
});
|
||||
update();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
const esc = (s) =>
|
||||
s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
class DonutChart extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['data-items'];
|
||||
return ["data-items"];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
requestAnimationFrame(() => this.render());
|
||||
this._themeObserver = new MutationObserver(() => requestAnimationFrame(() => this.render()));
|
||||
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
this._themeObserver = new MutationObserver(() =>
|
||||
requestAnimationFrame(() => this.render()),
|
||||
);
|
||||
this._themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -26,22 +38,34 @@ class DonutChart extends HTMLElement {
|
|||
}
|
||||
|
||||
render() {
|
||||
const raw = this.dataset.items || '';
|
||||
if (!raw) { this.innerHTML = ''; return; }
|
||||
const raw = this.dataset.items || "";
|
||||
if (!raw) {
|
||||
this.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const items = raw.split('|').map(s => {
|
||||
const idx = s.lastIndexOf(':');
|
||||
if (idx === -1) return null;
|
||||
const label = s.slice(0, idx).trim();
|
||||
const count = parseInt(s.slice(idx + 1), 10);
|
||||
return (label && count > 0) ? { label, count } : null;
|
||||
}).filter(Boolean);
|
||||
const items = raw
|
||||
.split("|")
|
||||
.map((s) => {
|
||||
const idx = s.lastIndexOf(":");
|
||||
if (idx === -1) return null;
|
||||
const label = s.slice(0, idx).trim();
|
||||
const count = parseInt(s.slice(idx + 1), 10);
|
||||
return label && count > 0 ? { label, count } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (items.length === 0) { this.innerHTML = ''; return; }
|
||||
if (items.length === 0) {
|
||||
this.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const total = items.reduce((sum, i) => sum + i.count, 0);
|
||||
const maxCount = items[0].count;
|
||||
const rgb = getComputedStyle(document.documentElement).getPropertyValue('--highlight-rgb').trim() || '185, 28, 28';
|
||||
const rgb =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--highlight-rgb")
|
||||
.trim() || "185, 28, 28";
|
||||
const colorFor = (count) => {
|
||||
const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2);
|
||||
return `rgba(${rgb}, ${alpha})`;
|
||||
|
|
@ -70,7 +94,7 @@ class DonutChart extends HTMLElement {
|
|||
});
|
||||
|
||||
const legend = items.map((item, i) => {
|
||||
const pct = Math.round(item.count / total * 100);
|
||||
const pct = Math.round((item.count / total) * 100);
|
||||
return `<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);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,31 @@
|
|||
customElements.define("brew-photo-capture", class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.hidden = true;
|
||||
this.appendChild(input);
|
||||
customElements.define(
|
||||
"brew-photo-capture",
|
||||
class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.hidden = true;
|
||||
this.appendChild(input);
|
||||
|
||||
this.addEventListener("click", (e) => {
|
||||
if (e.target !== input) input.click();
|
||||
});
|
||||
this.addEventListener("click", (e) => {
|
||||
if (e.target !== input) input.click();
|
||||
});
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
document.getElementById(this.getAttribute("target-input")).value = reader.result;
|
||||
document.getElementById(this.getAttribute("target-form")).requestSubmit();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
input.value = "";
|
||||
});
|
||||
}
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
document.getElementById(this.getAttribute("target-input")).value =
|
||||
reader.result;
|
||||
document
|
||||
.getElementById(this.getAttribute("target-form"))
|
||||
.requestSubmit();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
input.value = "";
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,104 +1,123 @@
|
|||
customElements.define("searchable-select", class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
requestAnimationFrame(() => this._setup());
|
||||
}
|
||||
|
||||
_setup() {
|
||||
if (this._initialized) return;
|
||||
this._initialized = true;
|
||||
|
||||
const name = this.getAttribute("name");
|
||||
const placeholder = this.getAttribute("placeholder") || "Type to search\u2026";
|
||||
const buttons = [...this.querySelectorAll("button")];
|
||||
|
||||
const hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = name;
|
||||
|
||||
const search = document.createElement("input");
|
||||
search.type = "text";
|
||||
search.className = "input-field w-full";
|
||||
search.placeholder = placeholder;
|
||||
|
||||
const options = document.createElement("div");
|
||||
options.className = "hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface";
|
||||
buttons.forEach((btn) => options.appendChild(btn));
|
||||
|
||||
const searchWrap = document.createElement("div");
|
||||
searchWrap.appendChild(search);
|
||||
searchWrap.appendChild(options);
|
||||
|
||||
const display = document.createElement("span");
|
||||
display.className = "input-field w-full block pr-8";
|
||||
|
||||
const clear = document.createElement("button");
|
||||
clear.type = "button";
|
||||
clear.className = "absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition";
|
||||
clear.innerHTML = '<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";
|
||||
selectedWrap.appendChild(display);
|
||||
selectedWrap.appendChild(clear);
|
||||
|
||||
// Block native change events from child inputs so only our
|
||||
// CustomEvent (which carries evt.detail) reaches data-on:change.
|
||||
this.addEventListener("change", (e) => {
|
||||
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
|
||||
}, true);
|
||||
|
||||
this.textContent = "";
|
||||
this.style.display = "block";
|
||||
this.appendChild(hidden);
|
||||
this.appendChild(searchWrap);
|
||||
this.appendChild(selectedWrap);
|
||||
|
||||
const initialValue = this.getAttribute("initial-value");
|
||||
if (initialValue) {
|
||||
const match = buttons.find((btn) => btn.value === initialValue);
|
||||
if (match) {
|
||||
hidden.value = match.value;
|
||||
display.textContent = match.dataset.display;
|
||||
searchWrap.classList.add("hidden");
|
||||
selectedWrap.classList.remove("hidden");
|
||||
}
|
||||
customElements.define(
|
||||
"searchable-select",
|
||||
class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
requestAnimationFrame(() => this._setup());
|
||||
}
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
const q = search.value.toLowerCase();
|
||||
options.classList.toggle("hidden", !q);
|
||||
buttons.forEach((btn) => {
|
||||
btn.style.display = btn.textContent.toLowerCase().includes(q) ? "" : "none";
|
||||
_setup() {
|
||||
if (this._initialized) return;
|
||||
this._initialized = true;
|
||||
|
||||
const name = this.getAttribute("name");
|
||||
const placeholder =
|
||||
this.getAttribute("placeholder") || "Type to search\u2026";
|
||||
const buttons = [...this.querySelectorAll("button")];
|
||||
|
||||
const hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = name;
|
||||
|
||||
const search = document.createElement("input");
|
||||
search.type = "text";
|
||||
search.className = "input-field w-full";
|
||||
search.placeholder = placeholder;
|
||||
|
||||
const options = document.createElement("div");
|
||||
options.className =
|
||||
"hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface";
|
||||
buttons.forEach((btn) => options.appendChild(btn));
|
||||
|
||||
const searchWrap = document.createElement("div");
|
||||
searchWrap.appendChild(search);
|
||||
searchWrap.appendChild(options);
|
||||
|
||||
const display = document.createElement("span");
|
||||
display.className = "input-field w-full block pr-8";
|
||||
|
||||
const clear = document.createElement("button");
|
||||
clear.type = "button";
|
||||
clear.className =
|
||||
"absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition";
|
||||
clear.innerHTML =
|
||||
'<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";
|
||||
selectedWrap.appendChild(display);
|
||||
selectedWrap.appendChild(clear);
|
||||
|
||||
// Block native change events from child inputs so only our
|
||||
// CustomEvent (which carries evt.detail) reaches data-on:change.
|
||||
this.addEventListener(
|
||||
"change",
|
||||
(e) => {
|
||||
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
this.textContent = "";
|
||||
this.style.display = "block";
|
||||
this.appendChild(hidden);
|
||||
this.appendChild(searchWrap);
|
||||
this.appendChild(selectedWrap);
|
||||
|
||||
const initialValue = this.getAttribute("initial-value");
|
||||
if (initialValue) {
|
||||
const match = buttons.find((btn) => btn.value === initialValue);
|
||||
if (match) {
|
||||
hidden.value = match.value;
|
||||
display.textContent = match.dataset.display;
|
||||
searchWrap.classList.add("hidden");
|
||||
selectedWrap.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
const q = search.value.toLowerCase();
|
||||
options.classList.toggle("hidden", !q);
|
||||
buttons.forEach((btn) => {
|
||||
btn.style.display = btn.textContent.toLowerCase().includes(q)
|
||||
? ""
|
||||
: "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
options.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn || !options.contains(btn)) return;
|
||||
options.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn || !options.contains(btn)) return;
|
||||
|
||||
hidden.value = btn.value;
|
||||
display.textContent = btn.dataset.display;
|
||||
searchWrap.classList.add("hidden");
|
||||
selectedWrap.classList.remove("hidden");
|
||||
hidden.value = btn.value;
|
||||
display.textContent = btn.dataset.display;
|
||||
searchWrap.classList.add("hidden");
|
||||
selectedWrap.classList.remove("hidden");
|
||||
|
||||
this.dispatchEvent(new CustomEvent("change", {
|
||||
detail: { value: btn.value, display: btn.dataset.display, data: { ...btn.dataset } },
|
||||
bubbles: true,
|
||||
}));
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: {
|
||||
value: btn.value,
|
||||
display: btn.dataset.display,
|
||||
data: { ...btn.dataset },
|
||||
},
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const doClear = () => {
|
||||
hidden.value = "";
|
||||
display.textContent = "";
|
||||
search.value = "";
|
||||
searchWrap.classList.remove("hidden");
|
||||
selectedWrap.classList.add("hidden");
|
||||
options.classList.add("hidden");
|
||||
setTimeout(() => search.focus(), 0);
|
||||
const doClear = () => {
|
||||
hidden.value = "";
|
||||
display.textContent = "";
|
||||
search.value = "";
|
||||
searchWrap.classList.remove("hidden");
|
||||
selectedWrap.classList.add("hidden");
|
||||
options.classList.add("hidden");
|
||||
setTimeout(() => search.focus(), 0);
|
||||
|
||||
this.dispatchEvent(new CustomEvent("clear", { bubbles: true }));
|
||||
};
|
||||
this.dispatchEvent(new CustomEvent("clear", { bubbles: true }));
|
||||
};
|
||||
|
||||
selectedWrap.addEventListener("click", doClear);
|
||||
}
|
||||
});
|
||||
selectedWrap.addEventListener("click", doClear);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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}).`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
|
||||
const nextUrl = container.getAttribute("data-next-url")
|
||||
const targetSelector = container.getAttribute("data-target")
|
||||
if (!nextUrl || !targetSelector) {
|
||||
observer.disconnect()
|
||||
return
|
||||
}
|
||||
const nextUrl = container.getAttribute("data-next-url");
|
||||
const targetSelector =
|
||||
container.getAttribute("data-target");
|
||||
if (!nextUrl || !targetSelector) {
|
||||
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
|
||||
|
||||
// Append new rows to existing 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))
|
||||
})
|
||||
}
|
||||
|
||||
// Check if there are more pages
|
||||
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()
|
||||
} else {
|
||||
container.removeAttribute("data-next-url")
|
||||
container.removeAttribute("data-infinite-scroll")
|
||||
}
|
||||
fetch(nextUrl, {
|
||||
headers: { "datastar-request": "true" },
|
||||
})
|
||||
})
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
)
|
||||
.then((res) => res.text())
|
||||
.then((html) => {
|
||||
const doc = new DOMParser().parseFromString(
|
||||
html,
|
||||
"text/html",
|
||||
);
|
||||
const newTarget = doc.querySelector(targetSelector);
|
||||
if (!newTarget) return;
|
||||
|
||||
observer.observe(sentinel)
|
||||
})
|
||||
}
|
||||
// Append new rows to existing 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));
|
||||
});
|
||||
}
|
||||
|
||||
setupInfiniteScroll()
|
||||
// Check if there are more pages
|
||||
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();
|
||||
} else {
|
||||
container.removeAttribute("data-next-url");
|
||||
container.removeAttribute("data-infinite-scroll");
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
});
|
||||
};
|
||||
|
||||
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
|
|
@ -1,476 +1,568 @@
|
|||
{% 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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</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 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-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 %}
|
||||
</span>
|
||||
<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 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-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 %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if passkeys.len() > 1 %}
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if passkeys.len() > 1 %}
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if passkeys.len() <= 1 %}
|
||||
<p class="text-xs text-text-muted">Add another passkey before removing the only one.</p>
|
||||
{% endif %}
|
||||
{% if passkeys.len() <= 1 %}
|
||||
<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 class="flex items-end gap-3">
|
||||
<label class="flex-1 flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Passkey Name</span>
|
||||
<input
|
||||
type="text"
|
||||
id="passkey-name"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="e.g. MacBook Touch ID, iPhone"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="add-passkey-btn"
|
||||
type="button"
|
||||
onclick="registerPasskey()"
|
||||
class="shrink-0 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ icons::key("h-4 w-4") }} Register
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_showPasskeyForm = false; document.getElementById('add-passkey-error').classList.add('hidden'); document.getElementById('passkey-name').value = ''"
|
||||
class="shrink-0 inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt"
|
||||
>
|
||||
{{ 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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-show="!$_showPasskeyForm"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{% if tokens.is_empty() %}
|
||||
<p class="text-sm text-text-muted">No active tokens.</p>
|
||||
{% 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 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-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 %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% 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>
|
||||
<form
|
||||
data-on:submit="$_creatingToken = true; $_tokenError = ''; @post('/api/v1/tokens', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_creatingToken) return;
|
||||
if (evt.detail.type === 'finished') { $_creatingToken = false; document.getElementById('token-name').value = '' }
|
||||
else if (evt.detail.type === 'error') { $_creatingToken = false; $_tokenError = 'Failed to create token.' }"
|
||||
<!-- 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 class="flex items-end gap-3">
|
||||
<label class="flex-1 flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Token Name</span>
|
||||
<span class="text-text">Passkey Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="token-name"
|
||||
id="passkey-name"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="e.g. laptop-cli, ci-server"
|
||||
placeholder="e.g. MacBook Touch ID, iPhone"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
id="add-passkey-btn"
|
||||
type="button"
|
||||
onclick="registerPasskey()"
|
||||
class="shrink-0 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-attr:disabled="$_creatingToken"
|
||||
>
|
||||
{{ icons::plus("h-4 w-4") }} Create
|
||||
{{ icons::key("h-4 w-4") }} Register
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_showTokenForm = false; $_tokenError = ''"
|
||||
data-on:click="$_showPasskeyForm = false; document.getElementById('add-passkey-error').classList.add('hidden'); document.getElementById('passkey-name').value = ''"
|
||||
class="shrink-0 inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt"
|
||||
>
|
||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.reload()"
|
||||
class="absolute top-3.5 right-3 inline-flex h-6 w-6 items-center justify-center rounded text-green-800 dark:text-green-300 transition hover:text-green-600 dark:hover:text-green-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
{{ 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>
|
||||
<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>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick="copyToken(this)"
|
||||
class="shrink-0 inline-flex items-center gap-2 rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-green-50 transition hover:bg-green-500"
|
||||
data-show="!$_showPasskeyForm"
|
||||
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::clipboard("h-4 w-4") }} Copy
|
||||
{{ icons::key("h-4 w-4") }} New Passkey
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-show="!$_showTokenForm && !$_tokenCreated"
|
||||
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"
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
{% if tokens.is_empty() %}
|
||||
<p class="text-sm text-text-muted">No active tokens.</p>
|
||||
{% 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 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-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 %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Create token form -->
|
||||
<div
|
||||
class="rounded-md border bg-surface-alt p-4"
|
||||
data-show="$_showTokenForm && !$_tokenCreated"
|
||||
style="display: none"
|
||||
>
|
||||
{{ icons::terminal("h-4 w-4") }}
|
||||
New Token
|
||||
</button>
|
||||
<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;
|
||||
if (evt.detail.type === 'finished') { $_creatingToken = false; document.getElementById('token-name').value = '' }
|
||||
else if (evt.detail.type === 'error') { $_creatingToken = false; $_tokenError = 'Failed to create token.' }"
|
||||
>
|
||||
<div class="flex items-end gap-3">
|
||||
<label class="flex-1 flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Token Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="token-name"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="e.g. laptop-cli, ci-server"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="shrink-0 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-attr:disabled="$_creatingToken"
|
||||
>
|
||||
{{ icons::plus("h-4 w-4") }} Create
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_showTokenForm = false; $_tokenError = ''"
|
||||
class="shrink-0 inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt"
|
||||
>
|
||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.reload()"
|
||||
class="absolute top-3.5 right-3 inline-flex h-6 w-6 items-center justify-center rounded text-green-800 dark:text-green-300 transition hover:text-green-600 dark:hover:text-green-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
{{ 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>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onclick="copyToken(this)"
|
||||
class="shrink-0 inline-flex items-center gap-2 rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-green-50 transition hover:bg-green-500"
|
||||
>
|
||||
{{ icons::clipboard("h-4 w-4") }} Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-show="!$_showTokenForm && !$_tokenCreated"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<a
|
||||
href="/api/v1/backup"
|
||||
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
|
||||
</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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-signals:_recomputing="false"
|
||||
data-on:click="$_recomputing = true; @post('/api/v1/stats/recompute')"
|
||||
data-on:datastar-fetch="if (!$_recomputing) return;
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<a
|
||||
href="/api/v1/backup"
|
||||
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
|
||||
</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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-signals:_recomputing="false"
|
||||
data-on:click="$_recomputing = true; @post('/api/v1/stats/recompute')"
|
||||
data-on:datastar-fetch="if (!$_recomputing) return;
|
||||
if (evt.detail.type === 'finished') { $_recomputing = false; document.getElementById('backup-status').textContent = 'Stats recomputed successfully.'; document.getElementById('backup-status').classList.remove('hidden') }
|
||||
else if (evt.detail.type === 'error') { $_recomputing = false; document.getElementById('backup-error').textContent = 'Stats recompute failed.'; document.getElementById('backup-error').classList.remove('hidden') }"
|
||||
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>
|
||||
Recompute Stats
|
||||
</button>
|
||||
<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="resetDatabase()"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }}
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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>
|
||||
<p class="mt-1 text-sm text-text-secondary">Sign out on this device.</p>
|
||||
</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
|
||||
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
|
||||
>
|
||||
Recompute Stats
|
||||
</button>
|
||||
</form>
|
||||
<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="resetDatabase()"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }} Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// --- Passkey form ---
|
||||
<!-- 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>
|
||||
|
||||
const registerPasskey = async () => {
|
||||
const name = document.getElementById("passkey-name").value.trim();
|
||||
const errorEl = document.getElementById("add-passkey-error");
|
||||
const loadingEl = document.getElementById("add-passkey-loading");
|
||||
const btn = document.getElementById("add-passkey-btn");
|
||||
<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
|
||||
>
|
||||
</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
|
||||
>
|
||||
</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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = "Please enter a name for this passkey.";
|
||||
errorEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
<!-- 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>
|
||||
<p class="mt-1 text-sm text-text-secondary">Sign out on this device.</p>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
errorEl.classList.add("hidden");
|
||||
loadingEl.classList.remove("hidden");
|
||||
btn.disabled = true;
|
||||
<script>
|
||||
// --- Passkey form ---
|
||||
|
||||
try {
|
||||
await addPasskey(name);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
loadingEl.classList.add("hidden");
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
const registerPasskey = async () => {
|
||||
const name = document.getElementById("passkey-name").value.trim();
|
||||
const errorEl = document.getElementById("add-passkey-error");
|
||||
const loadingEl = document.getElementById("add-passkey-loading");
|
||||
const btn = document.getElementById("add-passkey-btn");
|
||||
|
||||
// --- Token ---
|
||||
|
||||
const copyToken = (btn) => {
|
||||
const token = document.getElementById("token-value").textContent;
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
btn.textContent = "Copied!";
|
||||
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
|
||||
});
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(document.getElementById("token-value"));
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Data management ---
|
||||
|
||||
const restoreFromFile = async (input) => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
input.value = "";
|
||||
|
||||
if (!confirm("Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = document.getElementById("backup-status");
|
||||
const error = document.getElementById("backup-error");
|
||||
status.classList.add("hidden");
|
||||
error.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
JSON.parse(text);
|
||||
|
||||
const response = await fetch("/api/v1/backup/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: text,
|
||||
});
|
||||
|
||||
if (response.status === 409) {
|
||||
throw new Error("Database is not empty. Restore requires an empty database.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Restore failed (HTTP ${response.status}).`);
|
||||
if (!name) {
|
||||
errorEl.textContent = "Please enter a name for this passkey.";
|
||||
errorEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = "Backup restored successfully.";
|
||||
status.classList.remove("hidden");
|
||||
} catch (err) {
|
||||
error.textContent = err instanceof SyntaxError ? "Invalid JSON file." : err.message;
|
||||
error.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
errorEl.classList.add("hidden");
|
||||
loadingEl.classList.remove("hidden");
|
||||
btn.disabled = true;
|
||||
|
||||
const resetDatabase = async () => {
|
||||
if (!confirm("Reset all coffee data?\n\nThis will permanently delete all roasters, roasts, bags, gear, brews, cafes, cups, and timeline events.\n\nThis cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("Confirm? All coffee data will be permanently deleted.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = document.getElementById("backup-status");
|
||||
const error = document.getElementById("backup-error");
|
||||
status.classList.add("hidden");
|
||||
error.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/backup/reset", { method: "POST" });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Reset failed (HTTP ${response.status}).`);
|
||||
}
|
||||
|
||||
status.textContent = "Database reset successfully. All coffee data has been deleted.";
|
||||
status.classList.remove("hidden");
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
const deletePasskey = async (id, name) => {
|
||||
if (!confirm(`Delete passkey "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/passkeys/${id}`, { method: "DELETE" });
|
||||
if (response.ok) {
|
||||
try {
|
||||
await addPasskey(name);
|
||||
window.location.reload();
|
||||
} else if (response.status === 409) {
|
||||
alert("Cannot delete the only passkey. Add another passkey first.");
|
||||
} else {
|
||||
alert("Failed to delete passkey.");
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
loadingEl.classList.add("hidden");
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Failed to delete passkey: ${err.message}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const revokeToken = async (id, name) => {
|
||||
if (!confirm(`Revoke token "${name}"? This cannot be undone.`)) return;
|
||||
// --- Token ---
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/tokens/${id}/revoke`, { method: "POST" });
|
||||
if (response.ok) {
|
||||
const row = document.getElementById(`token-row-${id}`);
|
||||
if (row) row.remove();
|
||||
const copyToken = (btn) => {
|
||||
const token = document.getElementById("token-value").textContent;
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
btn.textContent = "Copied!";
|
||||
setTimeout(() => {
|
||||
btn.textContent = "Copy";
|
||||
}, 2000);
|
||||
});
|
||||
} else {
|
||||
alert("Failed to revoke token.");
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(document.getElementById("token-value"));
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Failed to revoke token: ${err.message}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
};
|
||||
|
||||
// --- Data management ---
|
||||
|
||||
const restoreFromFile = async (input) => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
input.value = "";
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
"Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = document.getElementById("backup-status");
|
||||
const error = document.getElementById("backup-error");
|
||||
status.classList.add("hidden");
|
||||
error.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
JSON.parse(text);
|
||||
|
||||
const response = await fetch("/api/v1/backup/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: text,
|
||||
});
|
||||
|
||||
if (response.status === 409) {
|
||||
throw new Error(
|
||||
"Database is not empty. Restore requires an empty database.",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Restore failed (HTTP ${response.status}).`);
|
||||
}
|
||||
|
||||
status.textContent = "Backup restored successfully.";
|
||||
status.classList.remove("hidden");
|
||||
} catch (err) {
|
||||
error.textContent =
|
||||
err instanceof SyntaxError ? "Invalid JSON file." : err.message;
|
||||
error.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
const resetDatabase = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Reset all coffee data?\n\nThis will permanently delete all roasters, roasts, bags, gear, brews, cafes, cups, and timeline events.\n\nThis cannot be undone.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("Confirm? All coffee data will be permanently deleted.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = document.getElementById("backup-status");
|
||||
const error = document.getElementById("backup-error");
|
||||
status.classList.add("hidden");
|
||||
error.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/backup/reset", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Reset failed (HTTP ${response.status}).`);
|
||||
}
|
||||
|
||||
status.textContent =
|
||||
"Database reset successfully. All coffee data has been deleted.";
|
||||
status.classList.remove("hidden");
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
const deletePasskey = async (id, name) => {
|
||||
if (!confirm(`Delete passkey "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/passkeys/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else if (response.status === 409) {
|
||||
alert("Cannot delete the only passkey. Add another passkey first.");
|
||||
} else {
|
||||
alert("Failed to delete passkey.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Failed to delete passkey: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeToken = async (id, name) => {
|
||||
if (!confirm(`Revoke token "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/tokens/${id}/revoke`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
const row = document.getElementById(`token-row-${id}`);
|
||||
if (row) row.remove();
|
||||
} else {
|
||||
alert("Failed to revoke token.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Failed to revoke token: ${err.message}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -2,89 +2,109 @@
|
|||
{% import "partials/detail_cards.html" as detail %}
|
||||
{% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %}
|
||||
{% block description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %}
|
||||
{% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %}
|
||||
{% block og_description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %}
|
||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
||||
{% block content %}
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── 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>
|
||||
|
||||
{# ── 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">
|
||||
<h2 class="text-lg font-semibold text-text mb-4">Bag</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Amount</dt>
|
||||
<dd class="font-medium text-text">{{ bag.amount }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Status</dt>
|
||||
<dd>
|
||||
{% if bag.closed %}
|
||||
<span class="font-medium text-text">Closed</span>
|
||||
{% 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>
|
||||
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">{{ bag.remaining }} / {{ bag.amount }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
{% if let Some(rd) = bag.roast_date %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Roast Date</dt>
|
||||
<dd class="font-medium text-text">{{ rd }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Opened</dt>
|
||||
<dd class="font-medium text-text">{{ bag.created_date }}</dd>
|
||||
</div>
|
||||
{% if let Some(fd) = bag.finished_date %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Finished</dt>
|
||||
<dd class="font-medium text-text">{{ fd }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</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 !bag.closed %}
|
||||
<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')">
|
||||
{{ icons::x_mark("h-4 w-4") }} Close Bag
|
||||
</button>
|
||||
{% endif %}
|
||||
<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 }}')">
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block description %}
|
||||
{{ bag.roast_name }}
|
||||
by {{ bag.roaster_name }} — {{ bag.amount }} bag.
|
||||
{% endblock %}
|
||||
{% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %}
|
||||
{% block og_description %}
|
||||
{{ bag.roast_name }}
|
||||
by {{ bag.roaster_name }} — {{ bag.amount }} bag.
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── 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>
|
||||
|
||||
{# ── 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">
|
||||
<h2 class="text-lg font-semibold text-text mb-4">Bag</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Amount</dt>
|
||||
<dd class="font-medium text-text">{{ bag.amount }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Status</dt>
|
||||
<dd>
|
||||
{% if bag.closed %}
|
||||
<span class="font-medium text-text">Closed</span>
|
||||
{% 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>
|
||||
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">
|
||||
{{ bag.remaining }} / {{ bag.amount }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
{% if let Some(rd) = bag.roast_date %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Roast Date</dt>
|
||||
<dd class="font-medium text-text">{{ rd }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Opened</dt>
|
||||
<dd class="font-medium text-text">{{ bag.created_date }}</dd>
|
||||
</div>
|
||||
{% if let Some(fd) = bag.finished_date %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Finished</dt>
|
||||
<dd class="font-medium text-text">{{ fd }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</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 !bag.closed %}
|
||||
<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')"
|
||||
>
|
||||
{{ icons::x_mark("h-4 w-4") }} Close Bag
|
||||
</button>
|
||||
{% endif %}
|
||||
<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 }}')"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -2,95 +2,108 @@
|
|||
{% import "partials/detail_cards.html" as detail %}
|
||||
{% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
|
||||
{% block description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
|
||||
{% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
|
||||
{% block og_description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
|
||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
||||
{% block content %}
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── 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>
|
||||
|
||||
{# ── 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">
|
||||
<h2 class="text-lg font-semibold text-text mb-4">Gear</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Grinder</dt>
|
||||
<dd class="font-medium text-text">{{ brew.grinder_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Brewer</dt>
|
||||
<dd class="font-medium text-text">{{ brew.brewer_name }}</dd>
|
||||
</div>
|
||||
{% if let Some(fp) = brew.filter_paper_name %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Filter Paper</dt>
|
||||
<dd class="font-medium text-text">{{ fp }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── 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">
|
||||
<div>
|
||||
<dt class="text-text-muted">Coffee</dt>
|
||||
<dd class="font-medium text-text">{{ brew.coffee_weight }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Water</dt>
|
||||
<dd class="font-medium text-text">{{ brew.water_volume }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Temperature</dt>
|
||||
<dd class="font-medium text-text">{{ brew.water_temp }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Grind Setting</dt>
|
||||
<dd class="font-medium text-text">{{ brew.grind_setting }}</dd>
|
||||
</div>
|
||||
{% if let Some(time) = brew.brew_time %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Brew Time</dt>
|
||||
<dd class="font-medium text-text">{{ time }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if !brew.quick_notes_label.is_empty() %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Notes</dt>
|
||||
<dd class="font-medium text-text">{{ brew.quick_notes_label }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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 }}')">
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block description %}
|
||||
{{ brew.roast_name }}
|
||||
by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee,
|
||||
{{ brew.water_volume }} water.
|
||||
{% endblock %}
|
||||
{% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
|
||||
{% block og_description %}
|
||||
{{ brew.roast_name }}
|
||||
by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee,
|
||||
{{ brew.water_volume }} water.
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── 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>
|
||||
|
||||
{# ── 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">
|
||||
<h2 class="text-lg font-semibold text-text mb-4">Gear</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Grinder</dt>
|
||||
<dd class="font-medium text-text">{{ brew.grinder_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Brewer</dt>
|
||||
<dd class="font-medium text-text">{{ brew.brewer_name }}</dd>
|
||||
</div>
|
||||
{% if let Some(fp) = brew.filter_paper_name %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Filter Paper</dt>
|
||||
<dd class="font-medium text-text">{{ fp }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── 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">
|
||||
<div>
|
||||
<dt class="text-text-muted">Coffee</dt>
|
||||
<dd class="font-medium text-text">{{ brew.coffee_weight }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Water</dt>
|
||||
<dd class="font-medium text-text">{{ brew.water_volume }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Temperature</dt>
|
||||
<dd class="font-medium text-text">{{ brew.water_temp }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Grind Setting</dt>
|
||||
<dd class="font-medium text-text">{{ brew.grind_setting }}</dd>
|
||||
</div>
|
||||
{% if let Some(time) = brew.brew_time %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Brew Time</dt>
|
||||
<dd class="font-medium text-text">{{ time }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if !brew.quick_notes_label.is_empty() %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Notes</dt>
|
||||
<dd class="font-medium text-text">{{ brew.quick_notes_label }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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 }}')"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,53 +3,67 @@
|
|||
{% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · {{ cafe.name }}{% endblock %}
|
||||
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
|
||||
{% block og_description %}{{ cafe.name }} — {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }}{% endblock %}
|
||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
||||
{% block content %}
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">City</dt>
|
||||
<dd class="font-medium text-text">{{ cafe.city }}</dd>
|
||||
</div>
|
||||
<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 }}
|
||||
</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>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{{ detail::map_with_legend_1(cafe.map_countries, cafe.map_max, "Cafe", "") }}
|
||||
</div>
|
||||
|
||||
{% 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 }}')">
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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">
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">City</dt>
|
||||
<dd class="font-medium text-text">{{ cafe.city }}</dd>
|
||||
</div>
|
||||
<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 }}
|
||||
</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
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{{ detail::map_with_legend_1(cafe.map_countries, cafe.map_max, "Cafe", "") }}
|
||||
</div>
|
||||
|
||||
{% 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 }}')"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,328 +1,377 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import
|
||||
"partials/scan_input.html" as scan %} {% import "partials/location_search.html" as location %} {%
|
||||
block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||
<script>
|
||||
{{ location::location_search_js() }}
|
||||
</script>
|
||||
{% endblock %} {% block content %}
|
||||
<section
|
||||
id="checkin-root"
|
||||
data-signals:_step="1"
|
||||
data-signals:_cafe-id="''"
|
||||
data-signals:_cafe-name="''"
|
||||
data-signals:_cafe-city="''"
|
||||
data-signals:_cafe-country="''"
|
||||
data-signals:_cafe-lat="0"
|
||||
data-signals:_cafe-lng="0"
|
||||
data-signals:_cafe-website="''"
|
||||
data-signals:_cafe-search="''"
|
||||
data-signals:_roast-id="''"
|
||||
data-signals:_roast-name="''"
|
||||
data-signals:_roaster-name="''"
|
||||
data-signals:_error="''"
|
||||
data-signals:_submitting="false"
|
||||
data-signals:_locating="false"
|
||||
data-signals:_location-found="false"
|
||||
data-signals:_user-lat="0"
|
||||
data-signals:_user-lng="0"
|
||||
data-signals:_city-name="''"
|
||||
data-signals:_reviewing-cafe="false"
|
||||
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>
|
||||
</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>
|
||||
<!-- Accent overlay: first segment -->
|
||||
<div
|
||||
class="absolute bg-accent"
|
||||
style="height: 3px; top: 8px; left: 10px; right: 50%; display: none"
|
||||
data-show="$_step > 1"
|
||||
></div>
|
||||
<!-- Accent overlay: second segment -->
|
||||
<div
|
||||
class="absolute bg-accent"
|
||||
style="height: 3px; top: 8px; left: 50%; right: 10px; display: none"
|
||||
data-show="$_step > 2"
|
||||
></div>
|
||||
|
||||
<div class="relative flex justify-between">
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="h-5 w-5 rounded-full border-[3px] bg-page transition-all"
|
||||
data-class="{'border-accent': $_step >= 1, 'border-border': $_step < 1}"
|
||||
></div>
|
||||
<span
|
||||
class="mt-2 text-xs font-medium transition-colors"
|
||||
data-class="{'text-accent': $_step === 1, 'text-text': $_step > 1}"
|
||||
>Cafe</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="h-5 w-5 rounded-full border-[3px] bg-page transition-all"
|
||||
data-class="{'border-accent': $_step >= 2, 'border-border': $_step < 2}"
|
||||
></div>
|
||||
<span
|
||||
class="mt-2 text-xs font-medium transition-colors"
|
||||
data-class="{'text-accent': $_step === 2, 'text-text': $_step > 2, 'text-text-muted': $_step < 2}"
|
||||
>Coffee</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="h-5 w-5 rounded-full border-[3px] bg-page transition-all"
|
||||
data-class="{'border-accent': $_step >= 3, 'border-border': $_step < 3}"
|
||||
></div>
|
||||
<span
|
||||
class="mt-2 text-xs font-medium transition-colors"
|
||||
data-class="{'text-accent': $_step === 3, 'text-text-muted': $_step < 3}"
|
||||
>Submit</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected cafe summary (shown after selection) -->
|
||||
<div
|
||||
class="mt-4 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer"
|
||||
data-show="$_step > 1 && $_cafeName && !$_reviewingCafe"
|
||||
style="display: none"
|
||||
data-on:click="$_cafeId = ''; $_cafeName = ''; $_step = 1"
|
||||
{% 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="''"
|
||||
data-signals:_cafe-name="''"
|
||||
data-signals:_cafe-city="''"
|
||||
data-signals:_cafe-country="''"
|
||||
data-signals:_cafe-lat="0"
|
||||
data-signals:_cafe-lng="0"
|
||||
data-signals:_cafe-website="''"
|
||||
data-signals:_cafe-search="''"
|
||||
data-signals:_roast-id="''"
|
||||
data-signals:_roast-name="''"
|
||||
data-signals:_roaster-name="''"
|
||||
data-signals:_error="''"
|
||||
data-signals:_submitting="false"
|
||||
data-signals:_locating="false"
|
||||
data-signals:_location-found="false"
|
||||
data-signals:_user-lat="0"
|
||||
data-signals:_user-lng="0"
|
||||
data-signals:_city-name="''"
|
||||
data-signals:_reviewing-cafe="false"
|
||||
data-signals:_extracting="false"
|
||||
data-signals:_extract-error="''"
|
||||
data-signals:_scan-success="''"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ icons::location("h-4 w-4 text-accent shrink-0") }}
|
||||
<span class="font-medium text-text" data-text="$_cafeName"></span>
|
||||
<span
|
||||
class="text-xs text-text-muted"
|
||||
data-show="$_cafeCity"
|
||||
data-text="$_cafeCity"
|
||||
style="display: none"
|
||||
></span>
|
||||
<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>
|
||||
</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>
|
||||
<!-- Accent overlay: first segment -->
|
||||
<div
|
||||
class="absolute bg-accent"
|
||||
style="height: 3px; top: 8px; left: 10px; right: 50%; display: none"
|
||||
data-show="$_step > 1"
|
||||
></div>
|
||||
<!-- Accent overlay: second segment -->
|
||||
<div
|
||||
class="absolute bg-accent"
|
||||
style="height: 3px; top: 8px; left: 50%; right: 10px; display: none"
|
||||
data-show="$_step > 2"
|
||||
></div>
|
||||
|
||||
<div class="relative flex justify-between">
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="h-5 w-5 rounded-full border-[3px] bg-page transition-all"
|
||||
data-class="{'border-accent': $_step >= 1, 'border-border': $_step < 1}"
|
||||
></div>
|
||||
<span
|
||||
class="mt-2 text-xs font-medium transition-colors"
|
||||
data-class="{'text-accent': $_step === 1, 'text-text': $_step > 1}"
|
||||
>Cafe</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="h-5 w-5 rounded-full border-[3px] bg-page transition-all"
|
||||
data-class="{'border-accent': $_step >= 2, 'border-border': $_step < 2}"
|
||||
></div>
|
||||
<span
|
||||
class="mt-2 text-xs font-medium transition-colors"
|
||||
data-class="{'text-accent': $_step === 2, 'text-text': $_step > 2, 'text-text-muted': $_step < 2}"
|
||||
>Coffee</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="h-5 w-5 rounded-full border-[3px] bg-page transition-all"
|
||||
data-class="{'border-accent': $_step >= 3, 'border-border': $_step < 3}"
|
||||
></div>
|
||||
<span
|
||||
class="mt-2 text-xs font-medium transition-colors"
|
||||
data-class="{'text-accent': $_step === 3, 'text-text-muted': $_step < 3}"
|
||||
>Submit</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
<!-- Selected cafe summary (shown after selection) -->
|
||||
<div
|
||||
class="mt-4 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer"
|
||||
data-show="$_step > 1 && $_cafeName && !$_reviewingCafe"
|
||||
style="display: none"
|
||||
data-on:click="$_cafeId = ''; $_cafeName = ''; $_step = 1"
|
||||
class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
|
||||
>
|
||||
{{ icons::pencil("h-3 w-3") }} Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected roast summary (shown after selection) -->
|
||||
<div
|
||||
class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer"
|
||||
data-show="$_roastName && $_step > 2"
|
||||
style="display: none"
|
||||
data-on:click="$_roastId = ''; $_roastName = ''; $_roasterName = ''; $_scanSuccess = ''; $_step = 2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ icons::bag("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="$_roasterName"
|
||||
data-text="$_roasterName"
|
||||
style="display: none"
|
||||
></span>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ icons::location("h-4 w-4 text-accent shrink-0") }}
|
||||
<span class="font-medium text-text" data-text="$_cafeName"></span>
|
||||
<span
|
||||
class="text-xs text-text-muted"
|
||||
data-show="$_cafeCity"
|
||||
data-text="$_cafeCity"
|
||||
style="display: none"
|
||||
></span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_cafeId = ''; $_cafeName = ''; $_step = 1"
|
||||
class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
|
||||
>
|
||||
{{ icons::pencil("h-3 w-3") }} Change
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
<!-- Selected roast summary (shown after selection) -->
|
||||
<div
|
||||
class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer"
|
||||
data-show="$_roastName && $_step > 2"
|
||||
style="display: none"
|
||||
data-on:click="$_roastId = ''; $_roastName = ''; $_roasterName = ''; $_scanSuccess = ''; $_step = 2"
|
||||
class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
|
||||
>
|
||||
{{ icons::pencil("h-3 w-3") }} Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<p
|
||||
class="mt-3 text-sm text-red-600"
|
||||
data-show="$_error"
|
||||
style="display: none"
|
||||
data-text="$_error"
|
||||
></p>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<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" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">City</span>
|
||||
<input type="text" data-bind:_cafe-city class="input-field" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Country</span>
|
||||
<input type="text" data-bind:_cafe-country class="input-field" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Website</span>
|
||||
<input
|
||||
type="url"
|
||||
data-bind:_cafe-website
|
||||
class="input-field"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt"
|
||||
data-on:click="$_reviewingCafe = false; $_cafeName = ''; $_cafeCity = ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex 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"
|
||||
data-on:click="$_reviewingCafe = false; $_step = 2"
|
||||
>
|
||||
Next {{ icons::chevron_right("h-4 w-4") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ icons::bag("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="$_roasterName"
|
||||
data-text="$_roasterName"
|
||||
style="display: none"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<searchable-select
|
||||
name="saved_cafe_id"
|
||||
placeholder="Type to search saved cafes…"
|
||||
data-on:change="$_cafeId = evt.detail.value; $_cafeName = evt.detail.display; $_cafeCity = evt.detail.data.city; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''; $_step = 2"
|
||||
>
|
||||
{% for cafe in cafe_options %}
|
||||
<button
|
||||
type="button"
|
||||
value="{{ cafe.id }}"
|
||||
data-display="{{ cafe.name }}"
|
||||
data-city="{{ cafe.city }}"
|
||||
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>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</searchable-select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_roastId = ''; $_roastName = ''; $_roasterName = ''; $_scanSuccess = ''; $_step = 2"
|
||||
class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
|
||||
>
|
||||
{{ icons::pencil("h-3 w-3") }} Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Roast identification -->
|
||||
<div class="mt-4" data-show="$_step === 2" style="display: none">
|
||||
<div class="rounded-lg border bg-surface p-5">
|
||||
<h3 class="text-base font-semibold text-text mb-3">Select a coffee</h3>
|
||||
<!-- Error display -->
|
||||
<p
|
||||
class="mt-3 text-sm text-red-600"
|
||||
data-show="$_error"
|
||||
style="display: none"
|
||||
data-text="$_error"
|
||||
></p>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<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"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">City</span>
|
||||
<input type="text" data-bind:_cafe-city class="input-field" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Country</span>
|
||||
<input type="text" data-bind:_cafe-country class="input-field" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Website</span>
|
||||
<input
|
||||
type="url"
|
||||
data-bind:_cafe-website
|
||||
class="input-field"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt"
|
||||
data-on:click="$_reviewingCafe = false; $_cafeName = ''; $_cafeCity = ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex 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"
|
||||
data-on:click="$_reviewingCafe = false; $_step = 2"
|
||||
>
|
||||
Next {{ icons::chevron_right("h-4 w-4") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<searchable-select
|
||||
name="saved_cafe_id"
|
||||
placeholder="Type to search saved cafes…"
|
||||
data-on:change="$_cafeId = evt.detail.value; $_cafeName = evt.detail.display; $_cafeCity = evt.detail.data.city; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''; $_step = 2"
|
||||
>
|
||||
{% for cafe in cafe_options %}
|
||||
<button
|
||||
type="button"
|
||||
value="{{ cafe.id }}"
|
||||
data-display="{{ cafe.name }}"
|
||||
data-city="{{ cafe.city }}"
|
||||
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
|
||||
>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</searchable-select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Roast identification -->
|
||||
<div class="mt-4" data-show="$_step === 2" style="display: none">
|
||||
<div class="rounded-lg border bg-surface p-5">
|
||||
<h3 class="text-base font-semibold text-text mb-3">Select a coffee</h3>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<form
|
||||
id="checkin-scan-form"
|
||||
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Scan failed. Please try again.' }"
|
||||
>
|
||||
{{
|
||||
scan::scan_input("checkin-scan-form", "checkin-image", "$_extracting",
|
||||
"$_extractError", "Describe a coffee bag…", "Scanning…")
|
||||
}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-4 rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-800"
|
||||
data-show="$_scanSuccess"
|
||||
style="display: none"
|
||||
>
|
||||
Scanned: <span class="font-medium" data-text="$_scanSuccess"></span>
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
<searchable-select
|
||||
name="roast_id"
|
||||
placeholder="Type to search existing roasts…"
|
||||
data-on:change="$_roastId = evt.detail.value; $_roastName = evt.detail.display; $_roasterName = evt.detail.data.roaster; $_step = 3"
|
||||
>
|
||||
{% for roast in roast_options %}
|
||||
<button
|
||||
type="button"
|
||||
value="{{ roast.id }}"
|
||||
data-display="{{ roast.name }}"
|
||||
data-roaster="{{ roast.roaster_name }}"
|
||||
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
|
||||
>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</searchable-select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 & 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>
|
||||
<dd
|
||||
class="text-right text-text"
|
||||
data-text="$_cafeCity ? $_cafeName + ', ' + $_cafeCity : $_cafeName"
|
||||
></dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-text-muted">Coffee</dt>
|
||||
<dd
|
||||
class="text-right text-text"
|
||||
data-text="$_roasterName ? $_roastName + ' (' + $_roasterName + ')' : $_roastName"
|
||||
></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<form
|
||||
id="checkin-scan-form"
|
||||
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Scan failed. Please try again.' }"
|
||||
data-on:submit="$_submitting = true; $_error = ''; @post('/api/v1/check-in', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_submitting) return; if (evt.detail.type === 'error') { $_submitting = false; $_error = 'Check-in failed. Please try again.' }"
|
||||
>
|
||||
{{ scan::scan_input("checkin-scan-form", "checkin-image", "$_extracting",
|
||||
"$_extractError", "Describe a coffee bag…", "Scanning…") }}
|
||||
<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_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="roast_id" data-attr:value="$_roastId" />
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
data-attr:disabled="$_submitting"
|
||||
>
|
||||
{{ icons::check("h-4 w-4") }} Check In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-4 rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-800"
|
||||
data-show="$_scanSuccess"
|
||||
style="display: none"
|
||||
>
|
||||
Scanned: <span class="font-medium" data-text="$_scanSuccess"></span>
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
<searchable-select
|
||||
name="roast_id"
|
||||
placeholder="Type to search existing roasts…"
|
||||
data-on:change="$_roastId = evt.detail.value; $_roastName = evt.detail.display; $_roasterName = evt.detail.data.roaster; $_step = 3"
|
||||
>
|
||||
{% for roast in roast_options %}
|
||||
<button
|
||||
type="button"
|
||||
value="{{ roast.id }}"
|
||||
data-display="{{ roast.name }}"
|
||||
data-roaster="{{ roast.roaster_name }}"
|
||||
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>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</searchable-select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 & 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>
|
||||
<dd
|
||||
class="text-right text-text"
|
||||
data-text="$_cafeCity ? $_cafeName + ', ' + $_cafeCity : $_cafeName"
|
||||
></dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-text-muted">Coffee</dt>
|
||||
<dd
|
||||
class="text-right text-text"
|
||||
data-text="$_roasterName ? $_roastName + ' (' + $_roasterName + ')' : $_roastName"
|
||||
></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<form
|
||||
data-on:submit="$_submitting = true; $_error = ''; @post('/api/v1/check-in', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_submitting) return; if (evt.detail.type === 'error') { $_submitting = false; $_error = 'Check-in failed. Please try again.' }"
|
||||
>
|
||||
<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_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="roast_id" data-attr:value="$_roastId" />
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
data-attr:disabled="$_submitting"
|
||||
>
|
||||
{{ icons::check("h-4 w-4") }} Check In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,38 @@
|
|||
{% 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="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.
|
||||
</p>
|
||||
<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.
|
||||
</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"
|
||||
>
|
||||
{{ error.as_ref().unwrap() }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex flex-col items-center text-center gap-3">
|
||||
{{ icons::spinner("h-10 w-10") }}
|
||||
<h1 class="text-2xl font-semibold text-accent">CLI Authentication</h1>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Processing authentication…
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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">
|
||||
{{ error.as_ref().unwrap() }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex flex-col items-center text-center gap-3">
|
||||
{{ icons::spinner("h-10 w-10") }}
|
||||
<h1 class="text-2xl font-semibold text-accent">CLI Authentication</h1>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Processing authentication…
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -2,66 +2,83 @@
|
|||
{% import "partials/detail_cards.html" as detail %}
|
||||
{% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
|
||||
{% block description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
|
||||
{% block og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %}
|
||||
{% block og_description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
|
||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
||||
{% block content %}
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── 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>
|
||||
|
||||
{# ── 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">
|
||||
<h2 class="text-lg font-semibold text-text mb-4">Cafe</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Name</dt>
|
||||
<dd class="font-medium text-text">{{ cup.cafe_name }}</dd>
|
||||
</div>
|
||||
<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 }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">City</dt>
|
||||
<dd class="font-medium text-text">{{ cup.cafe_city }}</dd>
|
||||
</div>
|
||||
{% if let Some(url) = cup.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>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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 }}')">
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block description %}
|
||||
{{ cup.roast_name }}
|
||||
by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.
|
||||
{% endblock %}
|
||||
{% block og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %}
|
||||
{% block og_description %}
|
||||
{{ cup.roast_name }}
|
||||
by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── 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>
|
||||
|
||||
{# ── 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">
|
||||
<h2 class="text-lg font-semibold text-text mb-4">Cafe</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Name</dt>
|
||||
<dd class="font-medium text-text">{{ cup.cafe_name }}</dd>
|
||||
</div>
|
||||
<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 }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">City</dt>
|
||||
<dd class="font-medium text-text">{{ cup.cafe_city }}</dd>
|
||||
</div>
|
||||
{% if let Some(url) = cup.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
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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 }}')"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Data{% endblock %}
|
||||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · Data{% endblock %}
|
||||
{% block content %}
|
||||
<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 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>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{% include "partials/tab_bar.html" %}
|
||||
<!-- Search -->
|
||||
<div data-signals:_data-search="'{{ search_value }}'">
|
||||
<input
|
||||
type="search"
|
||||
data-bind:_data-search
|
||||
placeholder="Filter..."
|
||||
class="input-field w-full text-sm"
|
||||
data-on:input__debounce.300ms="history.pushState(null, '', '/data?type=' + $_activeTab + '&q=' + encodeURIComponent($_dataSearch)); @get('/data?type=' + $_activeTab + '&q=' + encodeURIComponent($_dataSearch), {responseOverrides: {selector: '#data-content', mode: 'inner'}})"
|
||||
{% if !search_value.is_empty() %}autofocus{% endif %}
|
||||
/>
|
||||
<div class="flex flex-col gap-4">
|
||||
{% include "partials/tab_bar.html" %}
|
||||
<!-- Search -->
|
||||
<div data-signals:_data-search="'{{ search_value }}'">
|
||||
<input
|
||||
type="search"
|
||||
data-bind:_data-search
|
||||
placeholder="Filter..."
|
||||
class="input-field w-full text-sm"
|
||||
data-on:input__debounce.300ms="history.pushState(null, '', '/data?type=' + $_activeTab + '&q=' + encodeURIComponent($_dataSearch)); @get('/data?type=' + $_activeTab + '&q=' + encodeURIComponent($_dataSearch), {responseOverrides: {selector: '#data-content', mode: 'inner'}})"
|
||||
{% if !search_value.is_empty() %}autofocus{% endif %}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="data-content" class="data-page-content">{{ content|safe }}</div>
|
||||
</div>
|
||||
|
||||
<div id="data-content" class="data-page-content">
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="detail-panel"></div>
|
||||
<div id="detail-panel"></div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -2,43 +2,51 @@
|
|||
{% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
|
||||
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
|
||||
{% block og_description %}{{ gear.make }} {{ gear.model }} — {{ gear.category_label }}{% endblock %}
|
||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
||||
{% block content %}
|
||||
<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>
|
||||
|
||||
<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">Category</dt>
|
||||
<dd class="font-medium text-text">{{ gear.category_label }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Make</dt>
|
||||
<dd class="font-medium text-text">{{ gear.make }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Model</dt>
|
||||
<dd class="font-medium text-text">{{ gear.model }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% 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 }}')">
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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">
|
||||
<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>
|
||||
|
||||
<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">Category</dt>
|
||||
<dd class="font-medium text-text">{{ gear.category_label }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Make</dt>
|
||||
<dd class="font-medium text-text">{{ gear.make }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Model</dt>
|
||||
<dd class="font-medium text-text">{{ gear.model }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% 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 }}')"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,376 +1,492 @@
|
|||
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import
|
||||
"partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %}
|
||||
<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"
|
||||
data-signals:_scan-submitting="false"
|
||||
data-signals:_scan-error="''"
|
||||
data-signals:_roaster-name="''"
|
||||
data-signals:_roaster-country="''"
|
||||
data-signals:_roaster-city="''"
|
||||
data-signals:_roaster-homepage="''"
|
||||
data-signals:_roast-name="''"
|
||||
data-signals:_origin="''"
|
||||
data-signals:_region="''"
|
||||
data-signals:_producer="''"
|
||||
data-signals:_process="''"
|
||||
data-signals:_tasting-notes="''"
|
||||
data-signals:_open-bag="true"
|
||||
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"
|
||||
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="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"
|
||||
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"
|
||||
>
|
||||
{{ icons::location("h-5 w-5") }}
|
||||
<span class="text-sm font-medium">Check In</span>
|
||||
</a>
|
||||
<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"
|
||||
>
|
||||
{{ icons::plus("h-5 w-5") }}
|
||||
<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…
|
||||
</div>
|
||||
<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) -->
|
||||
<div data-show="$_scanExtracted" style="display: none">
|
||||
<form
|
||||
class="rounded-lg border bg-surface p-5 flex flex-col gap-6"
|
||||
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
|
||||
>
|
||||
{% include "partials/forms/scan_result_form.html" %}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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"
|
||||
>View all brews →</a
|
||||
>
|
||||
</div>
|
||||
{% if !recent_brews.is_empty() %}
|
||||
<chip-scroll class="relative block">
|
||||
<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'})">
|
||||
{{ 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>
|
||||
<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'})">
|
||||
{{ 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">
|
||||
{% 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="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>
|
||||
</div>
|
||||
<div class="mt-2 flex-1 space-y-2">
|
||||
<div class="h-3 w-full rounded bg-surface-alt"></div>
|
||||
<div class="h-3 w-5/6 rounded bg-surface-alt"></div>
|
||||
<div class="h-3 w-2/3 rounded bg-surface-alt"></div>
|
||||
<div class="h-3 w-3/4 rounded bg-surface-alt"></div>
|
||||
</div>
|
||||
<div class="mt-3 h-8 rounded-md bg-surface-alt"></div>
|
||||
</div>
|
||||
{% 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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
>View all bags →</a
|
||||
>
|
||||
</div>
|
||||
{% if !open_bags.is_empty() %}
|
||||
<chip-scroll class="relative block">
|
||||
<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'})">
|
||||
{{ icons::chevron_left("h-5 w-5") }}
|
||||
</button>
|
||||
<div
|
||||
id="open-bags-grid"
|
||||
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 %}
|
||||
</div>
|
||||
<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'})">
|
||||
{{ 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">
|
||||
{% 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="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>
|
||||
</div>
|
||||
<div>
|
||||
<div class="h-2 w-full rounded-full bg-surface-alt"></div>
|
||||
<div class="mt-1 h-2 w-1/3 rounded bg-surface-alt"></div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<div class="flex-1 h-8 rounded-md bg-surface-alt"></div>
|
||||
<div class="flex-1 h-8 rounded-md bg-surface-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
>View full timeline →</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">
|
||||
<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 %}
|
||||
</p>
|
||||
</div>
|
||||
<time class="text-xs text-text-muted whitespace-nowrap shrink-0"
|
||||
>{{ event.relative_date_label }}</time
|
||||
>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="relative">
|
||||
<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="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>
|
||||
</div>
|
||||
<div class="h-3 w-16 shrink-0 rounded bg-surface-alt"></div>
|
||||
</div>
|
||||
{% 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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
>View all stats →</a
|
||||
>
|
||||
</div>
|
||||
{% if !stats.is_empty() && !stat_cards.is_empty() %}
|
||||
<chip-scroll class="relative block">
|
||||
<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'})">
|
||||
{{ 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>
|
||||
{% 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">
|
||||
{% 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>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<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'})">
|
||||
{{ 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">
|
||||
{% for _ in 0..6 %}
|
||||
<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>
|
||||
</div>
|
||||
{% 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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
>View all data →</a
|
||||
>
|
||||
</div>
|
||||
{% if !stats.is_empty() %}
|
||||
<chip-scroll class="relative block">
|
||||
<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'})">
|
||||
{{ 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>
|
||||
<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"
|
||||
>
|
||||
{{ icons::beaker("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.brews }}</span>
|
||||
<span class="text-sm font-medium text-accent">Brews</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=roasts"
|
||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||
>
|
||||
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.roasts }}</span>
|
||||
<span class="text-sm font-medium text-accent">Roasts</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=roasters"
|
||||
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-sm font-medium text-accent">Roasters</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=bags"
|
||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||
>
|
||||
{{ icons::bag("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.bags }}</span>
|
||||
<span class="text-sm font-medium text-accent">Bags</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=cups"
|
||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||
>
|
||||
{{ icons::cup("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.cups }}</span>
|
||||
<span class="text-sm font-medium text-accent">Cups</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=cafes"
|
||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||
>
|
||||
{{ icons::location("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.cafes }}</span>
|
||||
<span class="text-sm font-medium text-accent">Cafes</span>
|
||||
</a>
|
||||
</div>
|
||||
<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'})">
|
||||
{{ 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">
|
||||
{% for _ in 0..6 %}
|
||||
<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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
||||
<span class="text-lg font-semibold text-text-muted">No data yet</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
data-signals:_scan-submitting="false"
|
||||
data-signals:_scan-error="''"
|
||||
data-signals:_roaster-name="''"
|
||||
data-signals:_roaster-country="''"
|
||||
data-signals:_roaster-city="''"
|
||||
data-signals:_roaster-homepage="''"
|
||||
data-signals:_roast-name="''"
|
||||
data-signals:_origin="''"
|
||||
data-signals:_region="''"
|
||||
data-signals:_producer="''"
|
||||
data-signals:_process="''"
|
||||
data-signals:_tasting-notes="''"
|
||||
data-signals:_open-bag="true"
|
||||
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"
|
||||
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="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"
|
||||
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"
|
||||
>
|
||||
{{ icons::location("h-5 w-5") }}
|
||||
<span class="text-sm font-medium">Check In</span>
|
||||
</a>
|
||||
<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"
|
||||
>
|
||||
{{ icons::plus("h-5 w-5") }}
|
||||
<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…
|
||||
</div>
|
||||
<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) -->
|
||||
<div data-show="$_scanExtracted" style="display: none">
|
||||
<form
|
||||
class="rounded-lg border bg-surface p-5 flex flex-col gap-6"
|
||||
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
|
||||
>
|
||||
{% include "partials/forms/scan_result_form.html" %}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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"
|
||||
>View all brews →</a
|
||||
>
|
||||
</div>
|
||||
{% if !recent_brews.is_empty() %}
|
||||
<chip-scroll class="relative block">
|
||||
<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'})"
|
||||
>
|
||||
{{ 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>
|
||||
<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'})"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
{% 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="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>
|
||||
</div>
|
||||
<div class="mt-2 flex-1 space-y-2">
|
||||
<div class="h-3 w-full rounded bg-surface-alt"></div>
|
||||
<div class="h-3 w-5/6 rounded bg-surface-alt"></div>
|
||||
<div class="h-3 w-2/3 rounded bg-surface-alt"></div>
|
||||
<div class="h-3 w-3/4 rounded bg-surface-alt"></div>
|
||||
</div>
|
||||
<div class="mt-3 h-8 rounded-md bg-surface-alt"></div>
|
||||
</div>
|
||||
{% 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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
>View all bags →</a
|
||||
>
|
||||
</div>
|
||||
{% if !open_bags.is_empty() %}
|
||||
<chip-scroll class="relative block">
|
||||
<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'})"
|
||||
>
|
||||
{{ icons::chevron_left("h-5 w-5") }}
|
||||
</button>
|
||||
<div
|
||||
id="open-bags-grid"
|
||||
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 %}
|
||||
</div>
|
||||
<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'})"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
{% 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="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>
|
||||
</div>
|
||||
<div>
|
||||
<div class="h-2 w-full rounded-full bg-surface-alt"></div>
|
||||
<div class="mt-1 h-2 w-1/3 rounded bg-surface-alt"></div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<div class="flex-1 h-8 rounded-md bg-surface-alt"></div>
|
||||
<div class="flex-1 h-8 rounded-md bg-surface-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
>View full timeline →</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"
|
||||
>
|
||||
<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 %}
|
||||
</p>
|
||||
</div>
|
||||
<time class="text-xs text-text-muted whitespace-nowrap shrink-0"
|
||||
>{{ event.relative_date_label }}</time
|
||||
>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="relative">
|
||||
<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="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>
|
||||
</div>
|
||||
<div class="h-3 w-16 shrink-0 rounded bg-surface-alt"></div>
|
||||
</div>
|
||||
{% 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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
>View all stats →</a
|
||||
>
|
||||
</div>
|
||||
{% if !stats.is_empty() && !stat_cards.is_empty() %}
|
||||
<chip-scroll class="relative block">
|
||||
<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'})"
|
||||
>
|
||||
{{ 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
|
||||
>
|
||||
{% 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"
|
||||
>
|
||||
{% 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
|
||||
>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<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'})"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
{% for _ in 0..6 %}
|
||||
<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>
|
||||
</div>
|
||||
{% 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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
>View all data →</a
|
||||
>
|
||||
</div>
|
||||
{% if !stats.is_empty() %}
|
||||
<chip-scroll class="relative block">
|
||||
<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'})"
|
||||
>
|
||||
{{ 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
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{{ icons::beaker("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.brews }}</span>
|
||||
<span class="text-sm font-medium text-accent">Brews</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=roasts"
|
||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||
>
|
||||
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.roasts }}</span>
|
||||
<span class="text-sm font-medium text-accent">Roasts</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=roasters"
|
||||
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-sm font-medium text-accent">Roasters</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=bags"
|
||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||
>
|
||||
{{ icons::bag("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.bags }}</span>
|
||||
<span class="text-sm font-medium text-accent">Bags</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=cups"
|
||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||
>
|
||||
{{ icons::cup("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.cups }}</span>
|
||||
<span class="text-sm font-medium text-accent">Cups</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=cafes"
|
||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||
>
|
||||
{{ icons::location("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.cafes }}</span>
|
||||
<span class="text-sm font-medium text-accent">Cafes</span>
|
||||
</a>
|
||||
</div>
|
||||
<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'})"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
{% for _ in 0..6 %}
|
||||
<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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
||||
<span class="text-lg font-semibold text-text-muted">No data yet</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,89 @@
|
|||
{% 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="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 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>
|
||||
<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" >
|
||||
{{ icons::key("h-4 w-4") }} Sign in with Passkey
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{{ 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">
|
||||
<p>Waiting for passkey...</p>
|
||||
<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>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const button = document.getElementById("login-button");
|
||||
const errorDiv = document.getElementById("login-error");
|
||||
const loadingDiv = document.getElementById("login-loading");
|
||||
const unsupportedDiv = document.getElementById("login-unsupported");
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const button = document.getElementById("login-button");
|
||||
const errorDiv = document.getElementById("login-error");
|
||||
const loadingDiv = document.getElementById("login-loading");
|
||||
const unsupportedDiv = document.getElementById("login-unsupported");
|
||||
|
||||
// Check WebAuthn support
|
||||
if (!window.PublicKeyCredential) {
|
||||
button.disabled = true;
|
||||
unsupportedDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for CLI callback query params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let queryString = "";
|
||||
if (params.has("cli_callback")) {
|
||||
queryString = "?" + params.toString();
|
||||
}
|
||||
|
||||
button.addEventListener("click", async function () {
|
||||
errorDiv.classList.add("hidden");
|
||||
loadingDiv.classList.remove("hidden");
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const result = await startPasskeyAuthentication(queryString);
|
||||
|
||||
if (result.redirect) {
|
||||
window.location.href = result.redirect;
|
||||
} else {
|
||||
window.location.href = "/";
|
||||
}
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message;
|
||||
errorDiv.classList.remove("hidden");
|
||||
loadingDiv.classList.add("hidden");
|
||||
button.disabled = false;
|
||||
// Check WebAuthn support
|
||||
if (!window.PublicKeyCredential) {
|
||||
button.disabled = true;
|
||||
unsupportedDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for CLI callback query params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let queryString = "";
|
||||
if (params.has("cli_callback")) {
|
||||
queryString = "?" + params.toString();
|
||||
}
|
||||
|
||||
button.addEventListener("click", async function () {
|
||||
errorDiv.classList.add("hidden");
|
||||
loadingDiv.classList.remove("hidden");
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const result = await startPasskeyAuthentication(queryString);
|
||||
|
||||
if (result.redirect) {
|
||||
window.location.href = result.redirect;
|
||||
} else {
|
||||
window.location.href = "/";
|
||||
}
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message;
|
||||
errorDiv.classList.remove("hidden");
|
||||
loadingDiv.classList.add("hidden");
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,107 +1,118 @@
|
|||
{% 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="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.
|
||||
</p>
|
||||
<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.
|
||||
</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>
|
||||
<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">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Display Name</span>
|
||||
<input
|
||||
type="text"
|
||||
id="display-name"
|
||||
required
|
||||
autofocus
|
||||
class="input-field"
|
||||
placeholder="Display name"
|
||||
/>
|
||||
</label>
|
||||
<div id="register-form" class="mt-6 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Display Name</span>
|
||||
<input
|
||||
type="text"
|
||||
id="display-name"
|
||||
required
|
||||
autofocus
|
||||
class="input-field"
|
||||
placeholder="Display name"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Passkey Name</span>
|
||||
<input
|
||||
type="text"
|
||||
id="passkey-name"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="e.g. MacBook Touch ID, iPhone"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Passkey Name</span>
|
||||
<input
|
||||
type="text"
|
||||
id="passkey-name"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="e.g. MacBook Touch ID, iPhone"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<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" >
|
||||
{{ icons::key("h-4 w-4") }} Register Passkey
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{{ icons::key("h-4 w-4") }} Register Passkey
|
||||
</button>
|
||||
|
||||
<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
|
||||
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>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const token = "{{ token }}";
|
||||
const button = document.getElementById("register-button");
|
||||
const displayNameInput = document.getElementById("display-name");
|
||||
const passkeyNameInput = document.getElementById("passkey-name");
|
||||
const errorDiv = document.getElementById("register-error");
|
||||
const loadingDiv = document.getElementById("register-loading");
|
||||
const unsupportedDiv = document.getElementById("register-unsupported");
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const token = "{{ token }}";
|
||||
const button = document.getElementById("register-button");
|
||||
const displayNameInput = document.getElementById("display-name");
|
||||
const passkeyNameInput = document.getElementById("passkey-name");
|
||||
const errorDiv = document.getElementById("register-error");
|
||||
const loadingDiv = document.getElementById("register-loading");
|
||||
const unsupportedDiv = document.getElementById("register-unsupported");
|
||||
|
||||
// Check WebAuthn support
|
||||
if (!window.PublicKeyCredential) {
|
||||
button.disabled = true;
|
||||
unsupportedDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener("click", async function () {
|
||||
const displayName = displayNameInput.value.trim();
|
||||
if (!displayName) {
|
||||
errorDiv.textContent = "Please enter a display name.";
|
||||
errorDiv.classList.remove("hidden");
|
||||
// Check WebAuthn support
|
||||
if (!window.PublicKeyCredential) {
|
||||
button.disabled = true;
|
||||
unsupportedDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const passkeyName = passkeyNameInput.value.trim();
|
||||
if (!passkeyName) {
|
||||
errorDiv.textContent = "Please enter a name for this passkey.";
|
||||
errorDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
button.addEventListener("click", async function () {
|
||||
const displayName = displayNameInput.value.trim();
|
||||
if (!displayName) {
|
||||
errorDiv.textContent = "Please enter a display name.";
|
||||
errorDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
errorDiv.classList.add("hidden");
|
||||
loadingDiv.classList.remove("hidden");
|
||||
button.disabled = true;
|
||||
const passkeyName = passkeyNameInput.value.trim();
|
||||
if (!passkeyName) {
|
||||
errorDiv.textContent = "Please enter a name for this passkey.";
|
||||
errorDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await startPasskeyRegistration(token, displayName, passkeyName);
|
||||
window.location.href = "/";
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message;
|
||||
errorDiv.classList.remove("hidden");
|
||||
loadingDiv.classList.add("hidden");
|
||||
button.disabled = false;
|
||||
}
|
||||
errorDiv.classList.add("hidden");
|
||||
loadingDiv.classList.remove("hidden");
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
await startPasskeyRegistration(token, displayName, passkeyName);
|
||||
window.location.href = "/";
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message;
|
||||
errorDiv.classList.remove("hidden");
|
||||
loadingDiv.classList.add("hidden");
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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 content %}
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 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>
|
||||
|
||||
{% 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 }}')">
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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">
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 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>
|
||||
|
||||
{% 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 }}')"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,55 +3,71 @@
|
|||
{% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · {{ roaster.name }}{% endblock %}
|
||||
{% block og_title %}{{ roaster.name }} — Brewlog{% endblock %}
|
||||
{% block og_description %}{{ roaster.name }} — {{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %}, {{ c }}{% endif %}{% endblock %}
|
||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
||||
{% block content %}
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if let Some(c) = roaster.city %}
|
||||
<div>
|
||||
<dt class="text-text-muted">City</dt>
|
||||
<dd class="font-medium text-text">{{ c }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(url) = roaster.homepage %}
|
||||
<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>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{{ detail::map_with_legend_1(roaster.map_countries, roaster.map_max, "Roaster", "") }}
|
||||
</div>
|
||||
|
||||
{% 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 }}')">
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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">
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if let Some(c) = roaster.city %}
|
||||
<div>
|
||||
<dt class="text-text-muted">City</dt>
|
||||
<dd class="font-medium text-text">{{ c }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(url) = roaster.homepage %}
|
||||
<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
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{{ detail::map_with_legend_1(roaster.map_countries, roaster.map_max, "Roaster", "") }}
|
||||
</div>
|
||||
|
||||
{% 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 }}')"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,182 +1,247 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/histogram.html" as histogram %} {% block title %}Brewlog · Stats{% endblock %}
|
||||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||
{% import "partials/histogram.html" as histogram %}
|
||||
{% block title %}Brewlog · Stats{% endblock %}
|
||||
{% block og_title %}Stats — Brewlog{% endblock %}
|
||||
{% block og_description %}Aggregated coffee data across origins, consumption, and brewing.{% endblock %}
|
||||
{% block head %}<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">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="max-w-2xl text-sm text-text-secondary">Aggregated coffee data across origins, consumption, and brewing.</p>
|
||||
</header>
|
||||
<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
|
||||
>
|
||||
{% endif %}
|
||||
</div>
|
||||
<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">
|
||||
{% include "partials/tab_bar.html" %}
|
||||
{% if has_data %}
|
||||
<div class="flex flex-col gap-6">
|
||||
{% include "partials/tab_bar.html" %}
|
||||
|
||||
<div id="stats-content">
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
<div id="stats-content">{{ content|safe }}</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ icons::map("h-6 w-6 text-accent") }}
|
||||
<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">
|
||||
{{ 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 %}—{% 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">
|
||||
{{ 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 %}—{% endmatch %}</span>
|
||||
<span class="text-sm font-medium text-accent">Top Roaster</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if !roast_summary.origin_counts.is_empty() || !roast_summary.flavour_counts.is_empty() %}
|
||||
<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>
|
||||
{{ 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>
|
||||
{{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</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">
|
||||
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
|
||||
<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">
|
||||
{{ 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-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">
|
||||
{{ 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-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">
|
||||
{{ icons::beaker("h-6 w-6 text-accent") }}
|
||||
<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>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-semibold text-text">Brewing</h2>
|
||||
</div>
|
||||
{% if !brewing_summary.brewer_counts.is_empty() || !brewing_summary.grinder_counts.is_empty() %}
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
{% 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>
|
||||
</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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
{{ 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>
|
||||
{{ 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">
|
||||
<!-- Placeholder: Roast Stats -->
|
||||
<section>
|
||||
<div class="mb-5"><div class="h-5 w-28 rounded bg-surface-alt"></div></div>
|
||||
<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">
|
||||
{% for _ in 0..3 %}
|
||||
<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>
|
||||
<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-sm font-medium text-accent">Origins</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<!-- Placeholder: Consumption -->
|
||||
<section>
|
||||
<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="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>
|
||||
<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 %}—{% 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"
|
||||
>
|
||||
{{ 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 %}—{% endmatch %}</span
|
||||
>
|
||||
<span class="text-sm font-medium text-accent">Top Roaster</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<!-- Placeholder: Brewing charts -->
|
||||
<section>
|
||||
<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">
|
||||
<div class="h-4 w-28 rounded bg-surface-alt mb-4"></div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{% for _ in 0..4 %}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-28 h-3 rounded bg-surface-alt"></div>
|
||||
<div class="flex-1 h-5 rounded bg-surface-alt"></div>
|
||||
<div class="w-6 h-3 rounded bg-surface-alt"></div>
|
||||
{% if !roast_summary.origin_counts.is_empty() || !roast_summary.flavour_counts.is_empty() %}
|
||||
<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>
|
||||
{{ 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>
|
||||
{{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</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"
|
||||
>
|
||||
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
|
||||
<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"
|
||||
>
|
||||
{{ 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-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"
|
||||
>
|
||||
{{ 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-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"
|
||||
>
|
||||
{{ icons::beaker("h-6 w-6 text-accent") }}
|
||||
<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>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-semibold text-text">Brewing</h2>
|
||||
</div>
|
||||
{% if !brewing_summary.brewer_counts.is_empty() || !brewing_summary.grinder_counts.is_empty() %}
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
{% 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>
|
||||
</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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
{{ 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>
|
||||
{{ 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"
|
||||
>
|
||||
<!-- Placeholder: Roast Stats -->
|
||||
<section>
|
||||
<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="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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
<!-- Placeholder: Consumption -->
|
||||
<section>
|
||||
<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="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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<!-- Placeholder: Brewing charts -->
|
||||
<section>
|
||||
<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">
|
||||
<div class="h-4 w-28 rounded bg-surface-alt mb-4"></div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{% for _ in 0..4 %}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-28 h-3 rounded bg-surface-alt"></div>
|
||||
<div class="flex-1 h-5 rounded bg-surface-alt"></div>
|
||||
<div class="w-6 h-3 rounded bg-surface-alt"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,219 +1,241 @@
|
|||
{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %} {% block content %}
|
||||
<div>
|
||||
<section
|
||||
id="timeline-events"
|
||||
data-signals:_expanded-card="''"
|
||||
data-signals:_expanded-months="''"
|
||||
data-signals:_collapsed-cards="''"
|
||||
data-empty="{{ months.is_empty() }}"
|
||||
>
|
||||
{% if months.is_empty() %}
|
||||
<p
|
||||
class="rounded-lg border border-dashed p-6 text-sm text-text-secondary"
|
||||
data-role="timeline-empty-state"
|
||||
>
|
||||
No events yet.
|
||||
</p>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
id="timeline-loader"
|
||||
class="mt-8 flex flex-col items-center gap-3"
|
||||
data-next-url="{% if events.has_next() %}{{ navigator.fragment_page_href(events.next_page().unwrap()) }}{% else %}{% endif %}"
|
||||
data-has-more="{{ events.has_next() }}"
|
||||
{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %}
|
||||
{% block content %}
|
||||
<div>
|
||||
<section
|
||||
id="timeline-events"
|
||||
data-signals:_expanded-card="''"
|
||||
data-signals:_expanded-months="''"
|
||||
data-signals:_collapsed-cards="''"
|
||||
data-empty="{{ months.is_empty() }}"
|
||||
>
|
||||
<button
|
||||
id="timeline-load-more"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold text-accent transition hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-40"
|
||||
style="{% if months.is_empty() || !events.has_next() %}display: none{% endif %}"
|
||||
{% if months.is_empty() %}
|
||||
<p
|
||||
class="rounded-lg border border-dashed p-6 text-sm text-text-secondary"
|
||||
data-role="timeline-empty-state"
|
||||
>
|
||||
No events yet.
|
||||
</p>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
id="timeline-loader"
|
||||
class="mt-8 flex flex-col items-center gap-3"
|
||||
data-next-url="{% if events.has_next() %}{{ navigator.fragment_page_href(events.next_page().unwrap()) }}{% else %}{% endif %}"
|
||||
data-has-more="{{ events.has_next() }}"
|
||||
data-empty="{{ months.is_empty() }}"
|
||||
>
|
||||
<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-error" class="text-sm text-red-600" hidden></p>
|
||||
</div>
|
||||
<div id="timeline-sentinel" class="h-1"></div>
|
||||
</section>
|
||||
</div>
|
||||
<button
|
||||
id="timeline-load-more"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold text-accent transition hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-40"
|
||||
style="{% if months.is_empty() || !events.has_next() %}display: none{% endif %}"
|
||||
>
|
||||
<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-error" class="text-sm text-red-600" hidden></p>
|
||||
</div>
|
||||
<div id="timeline-sentinel" class="h-1"></div>
|
||||
</section>
|
||||
</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
|
||||
if (loader && sentinel) {
|
||||
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()
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
)
|
||||
|
||||
const setHasMore = (hasMore, url) => {
|
||||
hasMorePages = hasMore
|
||||
nextUrl = url || ""
|
||||
loader.dataset.hasMore = hasMore ? "true" : "false"
|
||||
loader.dataset.nextUrl = nextUrl
|
||||
|
||||
if (!hasMore) {
|
||||
if (loadMoreButton) loadMoreButton.style.display = "none"
|
||||
if (endLine) endLine.style.display = ""
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
const ensureItemsContainer = () => {
|
||||
if (itemsContainer) return itemsContainer
|
||||
// First load on a previously empty timeline: create the container
|
||||
const container = document.createElement("div")
|
||||
container.className = "timeline-list relative"
|
||||
container.id = "timeline-items"
|
||||
const line = document.createElement("div")
|
||||
line.className = "timeline-line absolute top-0 bottom-0 w-1 bg-border"
|
||||
line.setAttribute("aria-hidden", "true")
|
||||
container.appendChild(line)
|
||||
section.insertBefore(container, loader)
|
||||
return container
|
||||
}
|
||||
|
||||
const appendMonths = (chunk) => {
|
||||
const monthsFragment = chunk.querySelector("[data-chunk-months]")
|
||||
if (!monthsFragment) return
|
||||
|
||||
const container = ensureItemsContainer()
|
||||
|
||||
for (const node of Array.from(monthsFragment.children)) {
|
||||
// Skip duplicate month headings (same month spanning a page boundary)
|
||||
if (node.hasAttribute("data-timeline-month") && document.getElementById(node.id)) {
|
||||
continue
|
||||
}
|
||||
container.appendChild(node)
|
||||
}
|
||||
}
|
||||
|
||||
const clearEmptyState = () => {
|
||||
if (emptyState && !emptyState.hidden) {
|
||||
emptyState.hidden = true
|
||||
loader.dataset.empty = "false"
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || !hasMorePages || !nextUrl) return
|
||||
|
||||
loading = true
|
||||
if (statusLine) {
|
||||
statusLine.hidden = false
|
||||
}
|
||||
if (errorLine) {
|
||||
errorLine.hidden = true
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(nextUrl, {
|
||||
headers: {
|
||||
"X-Requested-With": "fetch",
|
||||
"datastar-request": "true",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected response: ${response.status}`)
|
||||
}
|
||||
const html = await response.text()
|
||||
const template = document.createElement("template")
|
||||
template.innerHTML = html.trim()
|
||||
const chunk = template.content.querySelector("[data-timeline-chunk]")
|
||||
if (!chunk) {
|
||||
throw new Error("Invalid timeline chunk payload")
|
||||
}
|
||||
|
||||
appendMonths(chunk)
|
||||
clearEmptyState()
|
||||
|
||||
const hasMore = chunk.dataset.hasMore === "true"
|
||||
const url = chunk.dataset.nextUrl || ""
|
||||
setHasMore(hasMore, url)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (errorLine) {
|
||||
errorLine.textContent = "Failed to load more events. Please try again."
|
||||
errorLine.hidden = false
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
if (statusLine) {
|
||||
statusLine.hidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loadMoreButton) {
|
||||
loadMoreButton.addEventListener("click", () => {
|
||||
void loadMore()
|
||||
})
|
||||
}
|
||||
|
||||
if (hasMorePages && loader.dataset.empty !== "true") {
|
||||
observer.observe(sentinel)
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky header detection - observe all current and future headings
|
||||
const stickyObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
entry.target.classList.toggle("is-stuck", !entry.isIntersecting)
|
||||
})
|
||||
},
|
||||
{ rootMargin: "-1px 0px 0px 0px", threshold: 1 }
|
||||
)
|
||||
|
||||
// Use MutationObserver to watch for new month headings being added
|
||||
const timelineItems = document.getElementById("timeline-items")
|
||||
if (timelineItems) {
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.matches?.(".timeline-heading")) {
|
||||
stickyObserver.observe(node)
|
||||
}
|
||||
const heading = node.querySelector?.(".timeline-heading")
|
||||
if (heading) {
|
||||
stickyObserver.observe(heading)
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (
|
||||
entries.some((entry) => entry.isIntersecting) &&
|
||||
hasMorePages &&
|
||||
nextUrl
|
||||
) {
|
||||
void loadMore();
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
mutationObserver.observe(timelineItems, { childList: true })
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
|
||||
// Observe existing headings
|
||||
document.querySelectorAll(".timeline-heading").forEach((heading) => {
|
||||
stickyObserver.observe(heading)
|
||||
})
|
||||
</script>
|
||||
const setHasMore = (hasMore, url) => {
|
||||
hasMorePages = hasMore;
|
||||
nextUrl = url || "";
|
||||
loader.dataset.hasMore = hasMore ? "true" : "false";
|
||||
loader.dataset.nextUrl = nextUrl;
|
||||
|
||||
if (!hasMore) {
|
||||
if (loadMoreButton) loadMoreButton.style.display = "none";
|
||||
if (endLine) endLine.style.display = "";
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureItemsContainer = () => {
|
||||
if (itemsContainer) return itemsContainer;
|
||||
// First load on a previously empty timeline: create the container
|
||||
const container = document.createElement("div");
|
||||
container.className = "timeline-list relative";
|
||||
container.id = "timeline-items";
|
||||
const line = document.createElement("div");
|
||||
line.className = "timeline-line absolute top-0 bottom-0 w-1 bg-border";
|
||||
line.setAttribute("aria-hidden", "true");
|
||||
container.appendChild(line);
|
||||
section.insertBefore(container, loader);
|
||||
return container;
|
||||
};
|
||||
|
||||
const appendMonths = (chunk) => {
|
||||
const monthsFragment = chunk.querySelector("[data-chunk-months]");
|
||||
if (!monthsFragment) return;
|
||||
|
||||
const container = ensureItemsContainer();
|
||||
|
||||
for (const node of Array.from(monthsFragment.children)) {
|
||||
// Skip duplicate month headings (same month spanning a page boundary)
|
||||
if (
|
||||
node.hasAttribute("data-timeline-month") &&
|
||||
document.getElementById(node.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
container.appendChild(node);
|
||||
}
|
||||
};
|
||||
|
||||
const clearEmptyState = () => {
|
||||
if (emptyState && !emptyState.hidden) {
|
||||
emptyState.hidden = true;
|
||||
loader.dataset.empty = "false";
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || !hasMorePages || !nextUrl) return;
|
||||
|
||||
loading = true;
|
||||
if (statusLine) {
|
||||
statusLine.hidden = false;
|
||||
}
|
||||
if (errorLine) {
|
||||
errorLine.hidden = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(nextUrl, {
|
||||
headers: {
|
||||
"X-Requested-With": "fetch",
|
||||
"datastar-request": "true",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected response: ${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = html.trim();
|
||||
const chunk = template.content.querySelector("[data-timeline-chunk]");
|
||||
if (!chunk) {
|
||||
throw new Error("Invalid timeline chunk payload");
|
||||
}
|
||||
|
||||
appendMonths(chunk);
|
||||
clearEmptyState();
|
||||
|
||||
const hasMore = chunk.dataset.hasMore === "true";
|
||||
const url = chunk.dataset.nextUrl || "";
|
||||
setHasMore(hasMore, url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (errorLine) {
|
||||
errorLine.textContent =
|
||||
"Failed to load more events. Please try again.";
|
||||
errorLine.hidden = false;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
if (statusLine) {
|
||||
statusLine.hidden = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loadMoreButton) {
|
||||
loadMoreButton.addEventListener("click", () => {
|
||||
void loadMore();
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMorePages && loader.dataset.empty !== "true") {
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky header detection - observe all current and future headings
|
||||
const stickyObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
entry.target.classList.toggle("is-stuck", !entry.isIntersecting);
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-1px 0px 0px 0px", threshold: 1 },
|
||||
);
|
||||
|
||||
// Use MutationObserver to watch for new month headings being added
|
||||
const timelineItems = document.getElementById("timeline-items");
|
||||
if (timelineItems) {
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.matches?.(".timeline-heading")) {
|
||||
stickyObserver.observe(node);
|
||||
}
|
||||
const heading = node.querySelector?.(".timeline-heading");
|
||||
if (heading) {
|
||||
stickyObserver.observe(heading);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
mutationObserver.observe(timelineItems, { childList: true });
|
||||
}
|
||||
|
||||
// Observe existing headings
|
||||
document.querySelectorAll(".timeline-heading").forEach((heading) => {
|
||||
stickyObserver.observe(heading);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
</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>
|
||||
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">{{ bag.remaining }} / {{ bag.amount }}</p>
|
||||
<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>
|
||||
</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 }}';"
|
||||
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
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
<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>
|
||||
<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 }}';"
|
||||
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
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="mt-1 flex-1 space-y-1 text-text-secondary" style="font-size: 0.8125rem">
|
||||
<p>{{ brew.coffee_weight }} · {{ brew.water_volume }}{% if let Some(bt) = brew.brew_time %} · {{ bt }}{% endif %}</p>
|
||||
<p>{{ brew.grinder_model }} · {{ 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 %}
|
||||
</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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 flex-1 space-y-1 text-text-secondary"
|
||||
style="font-size: 0.8125rem"
|
||||
>
|
||||
<p>
|
||||
{{ brew.coffee_weight }} ·
|
||||
{{ brew.water_volume }}{% if let Some(bt) = brew.brew_time %}
|
||||
· {{ bt }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>{{ brew.grinder_model }} · {{ 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 %}
|
||||
</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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -1,167 +1,208 @@
|
|||
{% 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">
|
||||
<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-label">Share</span>
|
||||
</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"
|
||||
>
|
||||
<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-label">Share</span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro share_script() %}
|
||||
<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';
|
||||
setTimeout(() => {
|
||||
btn.querySelector('.share-icon').classList.remove('hidden');
|
||||
btn.querySelector('.share-icon-check').classList.add('hidden');
|
||||
btn.querySelector('.share-label').textContent = 'Share';
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<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";
|
||||
setTimeout(() => {
|
||||
btn.querySelector(".share-icon").classList.remove("hidden");
|
||||
btn.querySelector(".share-icon-check").classList.add("hidden");
|
||||
btn.querySelector(".share-label").textContent = "Share";
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
</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">
|
||||
<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>
|
||||
<dt class="text-text-muted">Roast</dt>
|
||||
<dd class="font-medium text-text">{{ roast_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Roaster</dt>
|
||||
<dd class="font-medium text-text">{{ roaster_name }}</dd>
|
||||
</div>
|
||||
{% if origin != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Origin</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{% if !origin_flag.is_empty() %}{{ origin_flag }} {% endif %}{{ origin }}
|
||||
</dd>
|
||||
</div>
|
||||
<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>
|
||||
<dt class="text-text-muted">Roast</dt>
|
||||
<dd class="font-medium text-text">{{ roast_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Roaster</dt>
|
||||
<dd class="font-medium text-text">{{ roaster_name }}</dd>
|
||||
</div>
|
||||
{% if origin != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Origin</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{% if !origin_flag.is_empty() %}{{ origin_flag }}{% endif %}{{ origin }}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if region != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Region</dt>
|
||||
<dd class="font-medium text-text">{{ region }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if producer != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Producer</dt>
|
||||
<dd class="font-medium text-text">{{ producer }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if process != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Process</dt>
|
||||
<dd class="font-medium text-text">{{ process }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% if !tasting_notes.is_empty() %}
|
||||
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||
{% for note in tasting_notes %}
|
||||
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if region != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Region</dt>
|
||||
<dd class="font-medium text-text">{{ region }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if producer != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Producer</dt>
|
||||
<dd class="font-medium text-text">{{ producer }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if process != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Process</dt>
|
||||
<dd class="font-medium text-text">{{ process }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% if !tasting_notes.is_empty() %}
|
||||
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||
{% for note in tasting_notes %}
|
||||
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro roaster_card(name, country, country_flag, city, homepage) %}
|
||||
<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>
|
||||
<dt class="text-text-muted">Name</dt>
|
||||
<dd class="font-medium text-text">{{ name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Country</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{% if !country_flag.is_empty() %}{{ country_flag }} {% endif %}{{ country }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if let Some(c) = city %}
|
||||
<div>
|
||||
<dt class="text-text-muted">City</dt>
|
||||
<dd class="font-medium text-text">{{ c }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(url) = homepage %}
|
||||
<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>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
<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>
|
||||
<dt class="text-text-muted">Name</dt>
|
||||
<dd class="font-medium text-text">{{ name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Country</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{% if !country_flag.is_empty() %}{{ country_flag }}{% endif %}{{ country }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if let Some(c) = city %}
|
||||
<div>
|
||||
<dt class="text-text-muted">City</dt>
|
||||
<dd class="font-medium text-text">{{ c }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(url) = homepage %}
|
||||
<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
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -1,150 +1,270 @@
|
|||
<!-- 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">
|
||||
<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="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>
|
||||
</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>
|
||||
</div>
|
||||
</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>
|
||||
<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 />
|
||||
</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 />
|
||||
</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 />
|
||||
</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 />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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="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>
|
||||
</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>
|
||||
</div>
|
||||
</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>
|
||||
<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
|
||||
/>
|
||||
</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
|
||||
/>
|
||||
</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
|
||||
/>
|
||||
</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
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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="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>
|
||||
</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>
|
||||
</div>
|
||||
</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>
|
||||
<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 />
|
||||
</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 />
|
||||
</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 />
|
||||
</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 />
|
||||
</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 />
|
||||
</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>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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="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>
|
||||
</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>
|
||||
</div>
|
||||
</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>
|
||||
<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
|
||||
/>
|
||||
</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
|
||||
/>
|
||||
</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
|
||||
/>
|
||||
</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
|
||||
/>
|
||||
</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
|
||||
/>
|
||||
</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>
|
||||
</label>
|
||||
</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 />
|
||||
<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 />
|
||||
</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…
|
||||
</div>
|
||||
<div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_scanExtracted = false; $_matchedRoasterId = ''; $_matchedRoastId = ''"
|
||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt"
|
||||
>
|
||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
||||
</button>
|
||||
<!-- Neither matched: full create -->
|
||||
<button
|
||||
type="submit"
|
||||
data-show="!$_matchedRoasterId"
|
||||
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"
|
||||
>
|
||||
{{ icons::plus("h-4 w-4") }} Save Roaster & Roast
|
||||
</button>
|
||||
<!-- Roaster matched, roast not: create roast only -->
|
||||
<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"
|
||||
>
|
||||
{{ icons::plus("h-4 w-4") }} Save Roast
|
||||
</button>
|
||||
<!-- Both matched + open bag: create bag only -->
|
||||
<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"
|
||||
>
|
||||
{{ 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 “Open a bag” above to add a new bag.
|
||||
</p>
|
||||
</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
|
||||
/>
|
||||
<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
|
||||
/>
|
||||
</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…
|
||||
</div>
|
||||
<div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_scanExtracted = false; $_matchedRoasterId = ''; $_matchedRoastId = ''"
|
||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt"
|
||||
>
|
||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
||||
</button>
|
||||
<!-- Neither matched: full create -->
|
||||
<button
|
||||
type="submit"
|
||||
data-show="!$_matchedRoasterId"
|
||||
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"
|
||||
>
|
||||
{{ icons::plus("h-4 w-4") }} Save Roaster & Roast
|
||||
</button>
|
||||
<!-- Roaster matched, roast not: create roast only -->
|
||||
<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"
|
||||
>
|
||||
{{ icons::plus("h-4 w-4") }} Save Roast
|
||||
</button>
|
||||
<!-- Both matched + open bag: create bag only -->
|
||||
<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"
|
||||
>
|
||||
{{ 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 “Open a bag” above to add a new
|
||||
bag.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,47 @@
|
|||
{% macro bar_chart(items, max_count) %}
|
||||
<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>
|
||||
<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>
|
||||
<span class="w-6 shrink-0 text-xs text-text-muted text-right">{{ item.1 }}</span>
|
||||
<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
|
||||
>
|
||||
<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>
|
||||
<span class="w-6 shrink-0 text-xs text-text-muted text-right"
|
||||
>{{ item.1 }}</span
|
||||
>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro bar_chart_weight(items, max_weight) %}
|
||||
<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>
|
||||
<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>
|
||||
<span class="w-14 shrink-0 text-xs text-text-muted text-right">{{ item.2 }}</span>
|
||||
<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
|
||||
>
|
||||
<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>
|
||||
<span class="w-14 shrink-0 text-xs text-text-muted text-right"
|
||||
>{{ item.2 }}</span
|
||||
>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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
|
||||
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>
|
||||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -3,102 +3,155 @@
|
|||
|
||||
<div id="bag-list" class="mt-6" data-star-scope="bags">
|
||||
{% if bags.items.is_empty() && !navigator.has_search() %}
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No bags added yet. Click Add to create the first bag.
|
||||
{% else %}
|
||||
No bags added yet.
|
||||
{% endif %}
|
||||
</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 %}
|
||||
>
|
||||
{{ 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">
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#bag-list") }}
|
||||
{{ table::sortable_header("Roaster", "roaster", navigator, "#bag-list") }}
|
||||
{{ table::sortable_header("Roast", "roast", navigator, "#bag-list") }}
|
||||
{{ table::sortable_header("Status", "status", navigator, "#bag-list") }}
|
||||
{{ table::sortable_header("Finished", "finished-at", navigator, "#bag-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for bag in bags.items %}
|
||||
<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">
|
||||
<div>{{ bag.created_date }}</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">
|
||||
{{ bag.roaster_name }}
|
||||
</td>
|
||||
<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">
|
||||
{% 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>
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
<div>{{ bag.finished_date }}</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<span class="md:hidden">
|
||||
{% if bag.closed %}
|
||||
<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">
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No bags added yet. Click Add to create the first bag.
|
||||
{% else %}
|
||||
No bags added yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if bags.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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 %}
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#bag-list") }}
|
||||
{{ table::sortable_header("Roaster", "roaster", navigator, "#bag-list") }}
|
||||
{{ table::sortable_header("Roast", "roast", navigator, "#bag-list") }}
|
||||
{{ table::sortable_header("Status", "status", navigator, "#bag-list") }}
|
||||
{{ table::sortable_header("Finished", "finished-at", navigator, "#bag-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for bag in bags.items %}
|
||||
<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"
|
||||
>
|
||||
<div>{{ bag.created_date }}</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"
|
||||
>
|
||||
{{ bag.roaster_name }}
|
||||
</td>
|
||||
<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"
|
||||
>
|
||||
{% 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>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div>{{ bag.finished_date }}</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<span class="md:hidden">
|
||||
{% if bag.closed %}
|
||||
<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"
|
||||
>
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if bags.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,106 +3,164 @@
|
|||
|
||||
<div id="brew-list" class="mt-6" data-star-scope="brews">
|
||||
{% if brews.items.is_empty() && !navigator.has_search() %}
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No brews logged yet. Click Add to log the first brew.
|
||||
{% else %}
|
||||
No brews logged yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No brews logged yet. Click Add to log the first brew.
|
||||
{% else %}
|
||||
No brews logged yet.
|
||||
{% endif %}
|
||||
</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 %}
|
||||
>
|
||||
{{ table::search_header(navigator, "#brew-list") }}
|
||||
<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">
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }}
|
||||
<th scope="col" class="px-4 py-3">Coffee</th>
|
||||
<th scope="col" class="px-4 py-3">Grind</th>
|
||||
<th scope="col" class="px-4 py-3">Recipe</th>
|
||||
<th scope="col" class="px-4 py-3">Brewer</th>
|
||||
<th scope="col" class="px-4 py-3">Notes</th>
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for brew in brews.items %}
|
||||
<tr class="transition hover:bg-surface-alt"
|
||||
onclick="window.location.href='/brews/{{ brew.id }}'"
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</td>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</td>
|
||||
{% if let Some(bt) = brew.brew_time %}
|
||||
<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="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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<td data-label="Notes" class="px-4 py-3">
|
||||
{% if !brew.quick_notes.is_empty() %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for qn in brew.quick_notes %}
|
||||
<span class="{{ qn.pill_class }}">{{ qn.label }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="pill pill-muted">No Notes</span>
|
||||
{% 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">
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }}
|
||||
<th scope="col" class="px-4 py-3">Coffee</th>
|
||||
<th scope="col" class="px-4 py-3">Grind</th>
|
||||
<th scope="col" class="px-4 py-3">Recipe</th>
|
||||
<th scope="col" class="px-4 py-3">Brewer</th>
|
||||
<th scope="col" class="px-4 py-3">Notes</th>
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for brew in brews.items %}
|
||||
<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"
|
||||
>
|
||||
<div>{{ brew.created_date }}</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"
|
||||
>
|
||||
<div class="font-medium text-text">{{ brew.roast_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="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>
|
||||
</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>
|
||||
</td>
|
||||
{% if let Some(bt) = brew.brew_time %}
|
||||
<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="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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<td data-label="Notes" class="px-4 py-3">
|
||||
{% if !brew.quick_notes.is_empty() %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for qn in brew.quick_notes %}
|
||||
<span class="{{ qn.pill_class }}">{{ qn.label }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="pill pill-muted">No Notes</span>
|
||||
{% 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"
|
||||
>
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if brews.items.is_empty() %}
|
||||
<div class="p-8 text-center text-text-muted">
|
||||
No brews match the search.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if brews.items.is_empty() %}
|
||||
<div class="p-8 text-center text-text-muted">
|
||||
No brews match the search.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ table::pagination_header(brews, navigator, "#brew-list") }}
|
||||
{{ table::pagination_header(brews, navigator, "#brew-list") }}
|
||||
|
||||
{% if brews.has_next() %}
|
||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% if brews.has_next() %}
|
||||
<div
|
||||
class="infinite-scroll-sentinel h-4 md:hidden"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,73 +3,119 @@
|
|||
|
||||
<div id="cafe-list" class="mt-6" data-star-scope="cafes">
|
||||
{% if cafes.items.is_empty() && !navigator.has_search() %}
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No cafes added yet. Click Add to add the first cafe.
|
||||
{% else %}
|
||||
No cafes added yet.
|
||||
{% endif %}
|
||||
</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 %}
|
||||
>
|
||||
{{ 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">
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#cafe-list") }}
|
||||
{{ table::sortable_header("Name", "name", navigator, "#cafe-list") }}
|
||||
{{ table::sortable_header("Country", "country", navigator, "#cafe-list") }}
|
||||
{{ table::sortable_header("City", "city", navigator, "#cafe-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for cafe in cafes.items %}
|
||||
<tr
|
||||
data-star-key="{{ cafe.id }}"
|
||||
data-sort-created-at="{{ cafe.created_at_sort_key }}"
|
||||
data-sort-name="{{ cafe.name }}"
|
||||
data-sort-country="{{ cafe.country }}"
|
||||
data-sort-city="{{ cafe.city }}"
|
||||
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">
|
||||
<div>{{ cafe.created_date }}</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">
|
||||
{{ 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="" 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">
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No cafes added yet. Click Add to add the first cafe.
|
||||
{% else %}
|
||||
No cafes added yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if cafes.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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 %}
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#cafe-list") }}
|
||||
{{ table::sortable_header("Name", "name", navigator, "#cafe-list") }}
|
||||
{{ table::sortable_header("Country", "country", navigator, "#cafe-list") }}
|
||||
{{ table::sortable_header("City", "city", navigator, "#cafe-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for cafe in cafes.items %}
|
||||
<tr
|
||||
data-star-key="{{ cafe.id }}"
|
||||
data-sort-created-at="{{ cafe.created_at_sort_key }}"
|
||||
data-sort-name="{{ cafe.name }}"
|
||||
data-sort-country="{{ cafe.country }}"
|
||||
data-sort-city="{{ cafe.city }}"
|
||||
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"
|
||||
>
|
||||
<div>{{ cafe.created_date }}</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"
|
||||
>
|
||||
{{ 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="" 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"
|
||||
>
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if cafes.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,74 +3,125 @@
|
|||
|
||||
<div id="cup-list" class="mt-6" data-star-scope="cups">
|
||||
{% if cups.items.is_empty() && !navigator.has_search() %}
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No cups added yet. Click Add to add the first cup.
|
||||
{% else %}
|
||||
No cups added yet.
|
||||
{% endif %}
|
||||
</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 %}
|
||||
>
|
||||
{{ 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">
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#cup-list") }}
|
||||
{{ table::sortable_header("Roast", "roast", navigator, "#cup-list") }}
|
||||
{{ table::sortable_header("Roaster", "roaster", navigator, "#cup-list") }}
|
||||
{{ table::sortable_header("Cafe", "cafe", navigator, "#cup-list") }}
|
||||
{{ table::sortable_header("City", "city", navigator, "#cup-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for cup in cups.items %}
|
||||
<tr
|
||||
data-star-key="{{ cup.id }}"
|
||||
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">
|
||||
<div>{{ cup.created_date }}</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">
|
||||
<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="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="" 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">
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No cups added yet. Click Add to add the first cup.
|
||||
{% else %}
|
||||
No cups added yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if cups.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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 %}
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#cup-list") }}
|
||||
{{ table::sortable_header("Roast", "roast", navigator, "#cup-list") }}
|
||||
{{ table::sortable_header("Roaster", "roaster", navigator, "#cup-list") }}
|
||||
{{ table::sortable_header("Cafe", "cafe", navigator, "#cup-list") }}
|
||||
{{ table::sortable_header("City", "city", navigator, "#cup-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for cup in cups.items %}
|
||||
<tr
|
||||
data-star-key="{{ cup.id }}"
|
||||
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"
|
||||
>
|
||||
<div>{{ cup.created_date }}</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"
|
||||
>
|
||||
<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="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="" 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"
|
||||
>
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if cups.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,71 +3,106 @@
|
|||
|
||||
<div id="gear-list" class="mt-6" data-star-scope="gear">
|
||||
{% if gear.items.is_empty() && !navigator.has_search() %}
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No gear added yet. Click Add to add the first piece of equipment.
|
||||
{% else %}
|
||||
No gear added yet.
|
||||
{% endif %}
|
||||
</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 %}
|
||||
>
|
||||
{{ 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">
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#gear-list") }}
|
||||
{{ table::sortable_header("Category", "category", navigator, "#gear-list") }}
|
||||
{{ table::sortable_header("Make", "make", navigator, "#gear-list") }}
|
||||
{{ table::sortable_header("Model", "model", navigator, "#gear-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for item in gear.items %}
|
||||
<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">
|
||||
<div>{{ item.created_date }}</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="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">
|
||||
{{ item.make }}
|
||||
</td>
|
||||
<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">
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No gear added yet. Click Add to add the first piece of equipment.
|
||||
{% else %}
|
||||
No gear added yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if gear.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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 %}
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#gear-list") }}
|
||||
{{ table::sortable_header("Category", "category", navigator, "#gear-list") }}
|
||||
{{ table::sortable_header("Make", "make", navigator, "#gear-list") }}
|
||||
{{ table::sortable_header("Model", "model", navigator, "#gear-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for item in gear.items %}
|
||||
<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"
|
||||
>
|
||||
<div>{{ item.created_date }}</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="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"
|
||||
>
|
||||
{{ item.make }}
|
||||
</td>
|
||||
<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"
|
||||
>
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if gear.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,92 +3,133 @@
|
|||
|
||||
<div id="roast-list" class="mt-6" data-star-scope="roasts">
|
||||
{% if roasts.items.is_empty() && !navigator.has_search() %}
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No roasts added yet. Click Add to create the first roast.
|
||||
{% else %}
|
||||
No roasts added yet.
|
||||
{% endif %}
|
||||
</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 %}
|
||||
>
|
||||
{{ 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">
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#roast-list") }}
|
||||
{{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
|
||||
{{ table::sortable_header("Origin", "origin", navigator, "#roast-list") }}
|
||||
<th scope="col" class="px-4 py-3">Tasting Notes</th>
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for roast in roasts.items %}
|
||||
<tr
|
||||
data-star-key="{{ roast.full_id }}"
|
||||
data-sort-created-at="{{ roast.created_at_sort_key }}"
|
||||
data-sort-name="{{ roast.name }}"
|
||||
data-sort-roaster="{{ roast.roaster_label }}"
|
||||
data-sort-origin="{{ roast.origin }}"
|
||||
data-sort-producer="{{ roast.producer }}"
|
||||
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">
|
||||
<div>{{ roast.created_date }}</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>
|
||||
</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.producer.is_empty() %}
|
||||
<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>
|
||||
{% 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">
|
||||
{% for note in roast.tasting_notes %}
|
||||
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="pill pill-muted">No Notes</span>
|
||||
{% 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">
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No roasts added yet. Click Add to create the first roast.
|
||||
{% else %}
|
||||
No roasts added yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if roasts.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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 %}
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#roast-list") }}
|
||||
{{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
|
||||
{{ table::sortable_header("Origin", "origin", navigator, "#roast-list") }}
|
||||
<th scope="col" class="px-4 py-3">Tasting Notes</th>
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for roast in roasts.items %}
|
||||
<tr
|
||||
data-star-key="{{ roast.full_id }}"
|
||||
data-sort-created-at="{{ roast.created_at_sort_key }}"
|
||||
data-sort-name="{{ roast.name }}"
|
||||
data-sort-roaster="{{ roast.roaster_label }}"
|
||||
data-sort-origin="{{ roast.origin }}"
|
||||
data-sort-producer="{{ roast.producer }}"
|
||||
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"
|
||||
>
|
||||
<div>{{ roast.created_date }}</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>
|
||||
</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.producer.is_empty() %}
|
||||
<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>
|
||||
{% 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"
|
||||
>
|
||||
{% for note in roast.tasting_notes %}
|
||||
<span class="{{ note.pill_class }}"
|
||||
>{{ note.label }}</span
|
||||
>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="pill pill-muted">No Notes</span>
|
||||
{% 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"
|
||||
>
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if roasts.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,75 +3,121 @@
|
|||
|
||||
<div id="roaster-list" class="mt-6" data-star-scope="roasters">
|
||||
{% if roasters.items.is_empty() && !navigator.has_search() %}
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No roasters added yet. Click Add to create the first roaster.
|
||||
{% else %}
|
||||
No roasters added yet.
|
||||
{% endif %}
|
||||
</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 %}
|
||||
>
|
||||
{{ 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">
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#roaster-list") }}
|
||||
{{ table::sortable_header("Name", "name", navigator, "#roaster-list") }}
|
||||
{{ table::sortable_header("Country", "country", navigator, "#roaster-list") }}
|
||||
{{ table::sortable_header("City", "city", navigator, "#roaster-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for roaster in roasters.items %}
|
||||
<tr
|
||||
data-star-key="{{ roaster.id }}"
|
||||
data-sort-created-at="{{ roaster.created_at_sort_key }}"
|
||||
data-sort-name="{{ roaster.name }}"
|
||||
data-sort-country="{{ roaster.country }}"
|
||||
data-sort-city="{{ roaster.city }}"
|
||||
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">
|
||||
<div>{{ roaster.created_date }}</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">
|
||||
{{ 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>
|
||||
{% if !roaster.city.is_empty() %}
|
||||
<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="" 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">
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||
>
|
||||
<p class="text-center">
|
||||
{% if is_authenticated %}
|
||||
No roasters added yet. Click Add to create the first roaster.
|
||||
{% else %}
|
||||
No roasters added yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if roasters.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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 %}
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
<tr>
|
||||
{{ table::sortable_header("Added", "created-at", navigator, "#roaster-list") }}
|
||||
{{ table::sortable_header("Name", "name", navigator, "#roaster-list") }}
|
||||
{{ table::sortable_header("Country", "country", navigator, "#roaster-list") }}
|
||||
{{ table::sortable_header("City", "city", navigator, "#roaster-list") }}
|
||||
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y/70">
|
||||
{% for roaster in roasters.items %}
|
||||
<tr
|
||||
data-star-key="{{ roaster.id }}"
|
||||
data-sort-created-at="{{ roaster.created_at_sort_key }}"
|
||||
data-sort-name="{{ roaster.name }}"
|
||||
data-sort-country="{{ roaster.country }}"
|
||||
data-sort-city="{{ roaster.city }}"
|
||||
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"
|
||||
>
|
||||
<div>{{ roaster.created_date }}</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"
|
||||
>
|
||||
{{ 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>
|
||||
{% if !roaster.city.is_empty() %}
|
||||
<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="" 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"
|
||||
>
|
||||
{{ icons::chevron_right("h-5 w-5") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if roasters.items.is_empty() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,98 +1,145 @@
|
|||
{% macro search_header(navigator, target_selector) %}
|
||||
<div class="border-b px-4 py-3">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value="{{ navigator.search_value() }}"
|
||||
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 %}
|
||||
/>
|
||||
</div>
|
||||
<div class="border-b px-4 py-3">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value="{{ navigator.search_value() }}"
|
||||
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 %}
|
||||
/>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro pagination_header(items, navigator, target_selector) %}
|
||||
<div
|
||||
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() }}–{{ items.end_index() }} of {{ items.total }}
|
||||
results
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
|
||||
{% 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'}})"
|
||||
{% else %}
|
||||
disabled
|
||||
{% endif %}
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
<span>Prev</span>
|
||||
</button>
|
||||
{% if items.is_showing_all() %}
|
||||
<span class="font-semibold text-text">Showing all results</span>
|
||||
<div
|
||||
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() }}–{{ items.end_index() }} of
|
||||
{{ items.total }} results
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
|
||||
{% 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'}})"
|
||||
{% else %}
|
||||
disabled
|
||||
{% endif %}
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
<span>Prev</span>
|
||||
</button>
|
||||
{% if items.is_showing_all() %}
|
||||
<span class="font-semibold text-text">Showing all results</span>
|
||||
{% else %}
|
||||
<span class="font-semibold text-text"
|
||||
>Page {{ items.page }} of {{ items.total_pages() }}</span
|
||||
>
|
||||
{% endif %}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
|
||||
{% 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'}})"
|
||||
{% else %}
|
||||
{% endif %}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
|
||||
{% 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'}})"
|
||||
{% else %}
|
||||
disabled
|
||||
{% endif %}
|
||||
>
|
||||
<span>Next</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
>
|
||||
<span>Next</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro sortable_header(label, key, navigator, target_selector) %}
|
||||
{% set is_sorted = navigator.is_sorted_by(key) %}
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3"
|
||||
aria-sort="{% if is_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||
{% set is_sorted = navigator.is_sorted_by(key) %}
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3"
|
||||
aria-sort="{% if is_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-text hover:text-accent"
|
||||
data-on:click="history.pushState(null, '', '{{ navigator.sort_href(key)|safe }}'); @get('{{ navigator.fragment_sort_href(key)|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-text hover:text-accent"
|
||||
data-on:click="history.pushState(null, '', '{{ navigator.sort_href(key)|safe }}'); @get('{{ navigator.fragment_sort_href(key)|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
{% if is_sorted %}
|
||||
<span>{{ label }}</span>
|
||||
{% if is_sorted %}
|
||||
{% if navigator.sort_direction() == "asc" %}
|
||||
<span aria-hidden="true">↑</span>
|
||||
<span aria-hidden="true">↑</span>
|
||||
{% else %}
|
||||
<span aria-hidden="true">↓</span>
|
||||
<span aria-hidden="true">↓</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% else %}
|
||||
<span aria-hidden="true">↕</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</th>
|
||||
{% endif %}
|
||||
</button>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,59 @@
|
|||
{% 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
|
||||
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"
|
||||
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}"
|
||||
data-attr:disabled="$_locating || $_locationFound"
|
||||
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>
|
||||
</button>
|
||||
<input type="text" data-bind:_city-name
|
||||
<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"
|
||||
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}"
|
||||
data-attr:disabled="$_locating || $_locationFound"
|
||||
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
|
||||
>
|
||||
</button>
|
||||
<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
|
||||
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'}})"
|
||||
placeholder="Search for a cafe…"
|
||||
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>
|
||||
<input type="text" data-bind:_cafe-search
|
||||
class="input-field w-full"
|
||||
placeholder="Search for a cafe…"
|
||||
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>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,103 @@
|
|||
{% 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="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">
|
||||
{{ 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()">
|
||||
<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">
|
||||
{{ 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">
|
||||
{{ icons::user("h-5 w-5") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<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="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"
|
||||
>
|
||||
{{ 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()"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
{{ icons::user("h-5 w-5") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
const isDark = html.getAttribute("data-theme") === "dark";
|
||||
const osDark = matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (isDark) {
|
||||
html.removeAttribute("data-theme");
|
||||
// Only persist if this choice differs from OS preference
|
||||
if (osDark) localStorage.setItem("theme", "light");
|
||||
else localStorage.removeItem("theme");
|
||||
} else {
|
||||
html.setAttribute("data-theme", "dark");
|
||||
if (!osDark) localStorage.setItem("theme", "dark");
|
||||
else localStorage.removeItem("theme");
|
||||
}
|
||||
updateThemeIcons();
|
||||
};
|
||||
|
||||
const updateThemeIcons = () => {
|
||||
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
|
||||
document.querySelectorAll(".theme-icon-light").forEach((el) => {
|
||||
el.classList.toggle("hidden", isDark);
|
||||
});
|
||||
document.querySelectorAll(".theme-icon-dark").forEach((el) => {
|
||||
el.classList.toggle("hidden", !isDark);
|
||||
});
|
||||
const favicon = document.getElementById("favicon");
|
||||
if (favicon) {
|
||||
favicon.href = isDark ? "/static/favicon-dark.svg" : "/static/favicon-light.svg";
|
||||
}
|
||||
};
|
||||
|
||||
// Set correct icon state on load
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
const isDark = html.getAttribute("data-theme") === "dark";
|
||||
const osDark = matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (isDark) {
|
||||
html.removeAttribute("data-theme");
|
||||
// Only persist if this choice differs from OS preference
|
||||
if (osDark) localStorage.setItem("theme", "light");
|
||||
else localStorage.removeItem("theme");
|
||||
} else {
|
||||
html.setAttribute("data-theme", "dark");
|
||||
if (!osDark) localStorage.setItem("theme", "dark");
|
||||
else localStorage.removeItem("theme");
|
||||
}
|
||||
updateThemeIcons();
|
||||
};
|
||||
|
||||
// Follow OS theme changes when the user hasn't manually chosen
|
||||
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
||||
if (localStorage.getItem("theme")) return;
|
||||
const html = document.documentElement;
|
||||
if (e.matches) {
|
||||
html.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
html.removeAttribute("data-theme");
|
||||
}
|
||||
updateThemeIcons();
|
||||
const updateThemeIcons = () => {
|
||||
const isDark =
|
||||
document.documentElement.getAttribute("data-theme") === "dark";
|
||||
document.querySelectorAll(".theme-icon-light").forEach((el) => {
|
||||
el.classList.toggle("hidden", isDark);
|
||||
});
|
||||
document.querySelectorAll(".theme-icon-dark").forEach((el) => {
|
||||
el.classList.toggle("hidden", !isDark);
|
||||
});
|
||||
const favicon = document.getElementById("favicon");
|
||||
if (favicon) {
|
||||
favicon.href = isDark
|
||||
? "/static/favicon-dark.svg"
|
||||
: "/static/favicon-light.svg";
|
||||
}
|
||||
};
|
||||
|
||||
// Set correct icon state on load
|
||||
updateThemeIcons();
|
||||
|
||||
// Follow OS theme changes when the user hasn't manually chosen
|
||||
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
||||
if (localStorage.getItem("theme")) return;
|
||||
const html = document.documentElement;
|
||||
if (e.matches) {
|
||||
html.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
html.removeAttribute("data-theme");
|
||||
}
|
||||
updateThemeIcons();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,32 @@
|
|||
<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>
|
||||
<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>
|
||||
{% for cafe in cafes %}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
|
||||
data-cafe-name="{{ cafe.name }}"
|
||||
data-cafe-city="{{ cafe.city }}"
|
||||
data-cafe-country="{{ cafe.country }}"
|
||||
data-cafe-lat="{{ cafe.latitude }}"
|
||||
data-cafe-lng="{{ cafe.longitude }}"
|
||||
data-cafe-website="{{ cafe.website }}"
|
||||
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 }} · {{ cafe.distance }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
<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"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
|
||||
data-cafe-name="{{ cafe.name }}"
|
||||
data-cafe-city="{{ cafe.city }}"
|
||||
data-cafe-country="{{ cafe.country }}"
|
||||
data-cafe-lat="{{ cafe.latitude }}"
|
||||
data-cafe-lng="{{ cafe.longitude }}"
|
||||
data-cafe-website="{{ cafe.website }}"
|
||||
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 }} · {{ cafe.distance }}</span
|
||||
>
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<optgroup id="roast-select-options">
|
||||
{% for roast in roasts %}
|
||||
<option value="{{ roast.roast.id }}">{{ roast.roast.name }}</option>
|
||||
<option value="{{ roast.roast.id }}">{{ roast.roast.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,36 @@
|
|||
{% 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 }}"
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
{{ icons::camera("h-5 w-5") }}
|
||||
</brew-photo-capture>
|
||||
<span class="text-xs text-text-muted">or</span>
|
||||
<input
|
||||
type="text"
|
||||
name="prompt"
|
||||
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"
|
||||
>
|
||||
{{ icons::camera("h-5 w-5") }}
|
||||
</brew-photo-capture>
|
||||
<span class="text-xs text-text-muted">or</span>
|
||||
<input
|
||||
type="text"
|
||||
name="prompt"
|
||||
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">
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,80 @@
|
|||
{% 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">
|
||||
<p class="text-center">No data recorded yet.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-lg border bg-surface overflow-hidden">
|
||||
<world-map
|
||||
class="block w-full"
|
||||
data-countries="{% for entry in geo_stats.entries %}{% if !entry.iso_code.is_empty() %}{% if !loop.first %},{% endif %}{{ entry.iso_code }}:{{ entry.count }}{% endif %}{% endfor %}"
|
||||
data-max="{{ geo_stats.max_count }}"
|
||||
></world-map>
|
||||
</div>
|
||||
|
||||
<chip-scroll class="relative block">
|
||||
<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'})">
|
||||
{{ 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>
|
||||
{% for entry in geo_stats.entries %}
|
||||
{% if !entry.iso_code.is_empty() %}
|
||||
<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')));">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<span class="border-l py-1.5 pl-2 pr-3 font-medium text-text">{{ entry.count }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<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>
|
||||
<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'})">
|
||||
{{ icons::chevron_right("h-4 w-4") }}
|
||||
</button>
|
||||
</chip-scroll>
|
||||
{% else %}
|
||||
<div class="rounded-lg border bg-surface overflow-hidden">
|
||||
<world-map
|
||||
class="block w-full"
|
||||
data-countries="{% for entry in geo_stats.entries %}{% if !entry.iso_code.is_empty() %}{% if !loop.first %},{% endif %}{{ entry.iso_code }}:{{ entry.count }}{% endif %}{% endfor %}"
|
||||
data-max="{{ geo_stats.max_count }}"
|
||||
></world-map>
|
||||
</div>
|
||||
|
||||
<chip-scroll class="relative block">
|
||||
<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'})"
|
||||
>
|
||||
{{ 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
|
||||
>
|
||||
{% for entry in geo_stats.entries %}
|
||||
{% if !entry.iso_code.is_empty() %}
|
||||
<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')));"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
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'})"
|
||||
>
|
||||
{{ icons::chevron_right("h-4 w-4") }}
|
||||
</button>
|
||||
</chip-scroll>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
{% 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 %}
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
{% 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>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,107 +18,150 @@
|
|||
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>
|
||||
{# Timeline node/bullet #}
|
||||
<span
|
||||
class="timeline-node absolute h-5 w-5 rounded-full border-[3px] border-accent bg-page"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div
|
||||
class="tl-card rounded-lg border bg-surface p-5 text-left"
|
||||
data-on:click="if (!evt.target.closest('a, button, form')) { if ($_expandedMonths.includes(',{{ month.anchor }}')) { $_collapsedCards = $_collapsedCards.includes(',{{ event.id }}') ? $_collapsedCards.replace(',{{ event.id }}', '') : $_collapsedCards + ',{{ event.id }}' } else { $_expandedCard = $_expandedCard.includes(',{{ event.id }}') ? $_expandedCard.replace(',{{ event.id }}', '') : $_expandedCard + ',{{ event.id }}' } }"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
{# Category — always visible #}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-text-muted">
|
||||
{% 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>
|
||||
{# Full timestamp — shown when expanded #}
|
||||
<time
|
||||
datetime="{{ event.iso_timestamp }}"
|
||||
class="tl-detail uppercase tracking-wide"
|
||||
<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"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div
|
||||
class="tl-card rounded-lg border bg-surface p-5 text-left"
|
||||
data-on:click="if (!evt.target.closest('a, button, form')) { if ($_expandedMonths.includes(',{{ month.anchor }}')) { $_collapsedCards = $_collapsedCards.includes(',{{ event.id }}') ? $_collapsedCards.replace(',{{ event.id }}', '') : $_collapsedCards + ',{{ event.id }}' } else { $_expandedCard = $_expandedCard.includes(',{{ event.id }}') ? $_expandedCard.replace(',{{ event.id }}', '') : $_expandedCard + ',{{ event.id }}' } }"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
{# Category — always visible #}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-text-muted">
|
||||
{% 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
|
||||
>
|
||||
{# 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
|
||||
>
|
||||
</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 }}')"
|
||||
>
|
||||
{{ 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"
|
||||
> · {{ 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 }}')">
|
||||
{{ 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">
|
||||
{{ 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>
|
||||
{# External link — shown when expanded #}
|
||||
{% if let Some(url) = event.external_link %}
|
||||
<a
|
||||
href="{{ url }}"
|
||||
class="tl-detail ml-auto text-accent transition hover:text-accent-hover"
|
||||
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
|
||||
style="display:none"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Open external link"
|
||||
>
|
||||
{{ icons::external_link("h-4 w-4") }}
|
||||
<span class="sr-only">Open external link</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
{# Subtitle — shown when collapsed #}
|
||||
{% if let Some(sub) = event.subtitle %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{# Detail rows — shown when expanded #}
|
||||
{% if event.details.len() > 0 %}
|
||||
<dl
|
||||
class="tl-detail mt-4 flex flex-col gap-2 text-sm text-text-secondary"
|
||||
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
|
||||
style="display:none"
|
||||
>
|
||||
{% for detail in event.details %}
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-text-muted">{{ detail.label }}</dt>
|
||||
{% 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">
|
||||
{{ icons::map("h-4 w-4") }}
|
||||
>
|
||||
{{ 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
|
||||
>
|
||||
{# External link — shown when expanded #}
|
||||
{% if let Some(url) = event.external_link %}
|
||||
<a
|
||||
href="{{ url }}"
|
||||
class="tl-detail ml-auto text-accent transition hover:text-accent-hover"
|
||||
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
|
||||
style="display:none"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Open external link"
|
||||
>
|
||||
{{ icons::external_link("h-4 w-4") }}
|
||||
<span class="sr-only">Open external link</span>
|
||||
</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>
|
||||
{% else %}
|
||||
<dd class="text-right">{{ detail.value }}</dd>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% 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>
|
||||
<dd class="flex flex-wrap gap-1 justify-end">
|
||||
{% for note in notes %}
|
||||
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||
</h3>
|
||||
{# Subtitle — shown when collapsed #}
|
||||
{% if let Some(sub) = event.subtitle %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{# Detail rows — shown when expanded #}
|
||||
{% if event.details.len() > 0 %}
|
||||
<dl
|
||||
class="tl-detail mt-4 flex flex-col gap-2 text-sm text-text-secondary"
|
||||
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
|
||||
style="display:none"
|
||||
>
|
||||
{% for detail in event.details %}
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-text-muted">{{ detail.label }}</dt>
|
||||
{% 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"
|
||||
>
|
||||
{{ 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>
|
||||
{% else %}
|
||||
<dd class="text-right">{{ detail.value }}</dd>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dd>
|
||||
</div>
|
||||
{% 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>
|
||||
<dd class="flex flex-wrap gap-1 justify-end">
|
||||
{% for note in notes %}
|
||||
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||
{% endfor %}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue