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" />
|
<input type="hidden" name="image" id="{id}-image" />
|
||||||
<div data-show="!$_extracting">
|
<div data-show="!$_extracting">
|
||||||
<brew-photo-capture target-input="{id}-image" target-form="{id}-extract-form" class="...">
|
<brew-photo-capture
|
||||||
|
target-input="{id}-image"
|
||||||
|
target-form="{id}-extract-form"
|
||||||
|
class="..."
|
||||||
|
>
|
||||||
Take Photo
|
Take Photo
|
||||||
</brew-photo-capture>
|
</brew-photo-capture>
|
||||||
<input name="prompt" type="text" placeholder="Or describe..." />
|
<input name="prompt" type="text" placeholder="Or describe..." />
|
||||||
|
|
@ -697,7 +701,13 @@ Label + input pattern:
|
||||||
```html
|
```html
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Field Name *</span>
|
<span class="text-text">Field Name *</span>
|
||||||
<input type="text" name="field" required class="input-field" placeholder="Example" />
|
<input
|
||||||
|
type="text"
|
||||||
|
name="field"
|
||||||
|
required
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Example"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -787,7 +797,9 @@ List partials live in `templates/partials/lists/`. Each follows:
|
||||||
|
|
||||||
<div id="{entity}-list" class="mt-6" data-star-scope="{entity}">
|
<div id="{entity}-list" class="mt-6" data-star-scope="{entity}">
|
||||||
{% if items.is_empty() && !navigator.has_search() %}
|
{% if items.is_empty() && !navigator.has_search() %}
|
||||||
<div class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary">
|
<div
|
||||||
|
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||||
|
>
|
||||||
<p class="text-center">No {entities} recorded yet.</p>
|
<p class="text-center">No {entities} recorded yet.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -797,10 +809,15 @@ List partials live in `templates/partials/lists/`. Each follows:
|
||||||
...
|
...
|
||||||
</table>
|
</table>
|
||||||
{% if items.is_empty() %}
|
{% if items.is_empty() %}
|
||||||
<div class="p-8 text-center text-text-muted">No {entities} match your search.</div>
|
<div class="p-8 text-center text-text-muted">
|
||||||
{% endif %} {% call table::pagination_header(items, navigator, "#{entity}-list") %} {% if
|
No {entities} match your search.
|
||||||
items.has_next() %}
|
</div>
|
||||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
{% endif %} {% call table::pagination_header(items, navigator,
|
||||||
|
"#{entity}-list") %} {% if items.has_next() %}
|
||||||
|
<div
|
||||||
|
class="infinite-scroll-sentinel h-4 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -834,14 +851,18 @@ Tables use the `responsive-table` CSS class (card layout on mobile, standard tab
|
||||||
```html
|
```html
|
||||||
<td data-label="Coffee" class="px-4 py-3 whitespace-nowrap">
|
<td data-label="Coffee" class="px-4 py-3 whitespace-nowrap">
|
||||||
<div class="font-medium">{{ brew.roast_name }}</div>
|
<div class="font-medium">{{ brew.roast_name }}</div>
|
||||||
<div class="hidden md:block text-xs text-text-muted">{{ brew.roaster_name }}</div>
|
<div class="hidden md:block text-xs text-text-muted">
|
||||||
|
{{ brew.roaster_name }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Mobile** — separate `<td>` for each sub-field:
|
**Mobile** — separate `<td>` for each sub-field:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.roaster_name }}</td>
|
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">
|
||||||
|
{{ brew.roaster_name }}
|
||||||
|
</td>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pagination & Infinite Scroll
|
### Pagination & Infinite Scroll
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,13 @@
|
||||||
/* ── Typography ────────────────────────────────────────────────── */
|
/* ── Typography ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family:
|
||||||
|
"Inter",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
@ -125,7 +131,9 @@ a {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
transition:
|
||||||
|
border-color 150ms ease,
|
||||||
|
box-shadow 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
input.input-field[type="number"]::-webkit-inner-spin-button,
|
input.input-field[type="number"]::-webkit-inner-spin-button,
|
||||||
|
|
@ -155,7 +163,9 @@ input.input-field[type="number"] {
|
||||||
background-color: var(--surface);
|
background-color: var(--surface);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
transition: background-color 150ms ease, color 150ms ease;
|
transition:
|
||||||
|
background-color 150ms ease,
|
||||||
|
color 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-adjust:hover {
|
.btn-adjust:hover {
|
||||||
|
|
@ -343,7 +353,10 @@ input.input-field[type="number"] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background-color: var(--surface);
|
background-color: var(--surface);
|
||||||
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
|
transition:
|
||||||
|
background-color 150ms ease,
|
||||||
|
color 150ms ease,
|
||||||
|
border-color 150ms ease;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,7 +381,9 @@ input.input-field[type="number"] {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
transition: background-color 150ms ease, color 150ms ease;
|
transition:
|
||||||
|
background-color 150ms ease,
|
||||||
|
color 150ms ease;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,7 +467,9 @@ input.input-field[type="number"] {
|
||||||
/* ── Data page ─────────────────────────────────────────────────── */
|
/* ── Data page ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Hide duplicate search inside data page - search is in the tab bar */
|
/* Hide duplicate search inside data page - search is in the tab bar */
|
||||||
.data-page-content section:has(.responsive-table) > div:first-child:has(input[type="search"]) {
|
.data-page-content
|
||||||
|
section:has(.responsive-table)
|
||||||
|
> div:first-child:has(input[type="search"]) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
customElements.define("chip-scroll", class extends HTMLElement {
|
customElements.define(
|
||||||
connectedCallback() { this._setup(); }
|
"chip-scroll",
|
||||||
|
class extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this._setup();
|
||||||
|
}
|
||||||
|
|
||||||
_setup() {
|
_setup() {
|
||||||
if (this._observer) this._observer.disconnect();
|
if (this._observer) this._observer.disconnect();
|
||||||
|
|
@ -16,7 +20,10 @@ customElements.define("chip-scroll", class extends HTMLElement {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none";
|
btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none";
|
||||||
btnR.style.display = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1 ? "flex" : "none";
|
btnR.style.display =
|
||||||
|
scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1
|
||||||
|
? "flex"
|
||||||
|
: "none";
|
||||||
};
|
};
|
||||||
|
|
||||||
scroller.addEventListener("scroll", update, { passive: true });
|
scroller.addEventListener("scroll", update, { passive: true });
|
||||||
|
|
@ -27,4 +34,5 @@ customElements.define("chip-scroll", class extends HTMLElement {
|
||||||
|
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,31 @@
|
||||||
const DONUT_ICONS = {
|
const DONUT_ICONS = {
|
||||||
beaker: '<path fill-rule="evenodd" d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z" clip-rule="evenodd" />',
|
beaker:
|
||||||
grinder: '<path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" /><rect x="5" y="16.5" width="10" height="1.5" rx=".5" />'
|
'<path fill-rule="evenodd" d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z" clip-rule="evenodd" />',
|
||||||
|
grinder:
|
||||||
|
'<path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" /><rect x="5" y="16.5" width="10" height="1.5" rx=".5" />',
|
||||||
};
|
};
|
||||||
|
|
||||||
const esc = (s) => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
const esc = (s) =>
|
||||||
|
s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
|
||||||
class DonutChart extends HTMLElement {
|
class DonutChart extends HTMLElement {
|
||||||
static get observedAttributes() {
|
static get observedAttributes() {
|
||||||
return ['data-items'];
|
return ["data-items"];
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
requestAnimationFrame(() => this.render());
|
requestAnimationFrame(() => this.render());
|
||||||
this._themeObserver = new MutationObserver(() => requestAnimationFrame(() => this.render()));
|
this._themeObserver = new MutationObserver(() =>
|
||||||
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
requestAnimationFrame(() => this.render()),
|
||||||
|
);
|
||||||
|
this._themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["data-theme"],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -26,22 +38,34 @@ class DonutChart extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const raw = this.dataset.items || '';
|
const raw = this.dataset.items || "";
|
||||||
if (!raw) { this.innerHTML = ''; return; }
|
if (!raw) {
|
||||||
|
this.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const items = raw.split('|').map(s => {
|
const items = raw
|
||||||
const idx = s.lastIndexOf(':');
|
.split("|")
|
||||||
|
.map((s) => {
|
||||||
|
const idx = s.lastIndexOf(":");
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null;
|
||||||
const label = s.slice(0, idx).trim();
|
const label = s.slice(0, idx).trim();
|
||||||
const count = parseInt(s.slice(idx + 1), 10);
|
const count = parseInt(s.slice(idx + 1), 10);
|
||||||
return (label && count > 0) ? { label, count } : null;
|
return label && count > 0 ? { label, count } : null;
|
||||||
}).filter(Boolean);
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
if (items.length === 0) { this.innerHTML = ''; return; }
|
if (items.length === 0) {
|
||||||
|
this.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const total = items.reduce((sum, i) => sum + i.count, 0);
|
const total = items.reduce((sum, i) => sum + i.count, 0);
|
||||||
const maxCount = items[0].count;
|
const maxCount = items[0].count;
|
||||||
const rgb = getComputedStyle(document.documentElement).getPropertyValue('--highlight-rgb').trim() || '185, 28, 28';
|
const rgb =
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--highlight-rgb")
|
||||||
|
.trim() || "185, 28, 28";
|
||||||
const colorFor = (count) => {
|
const colorFor = (count) => {
|
||||||
const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2);
|
const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2);
|
||||||
return `rgba(${rgb}, ${alpha})`;
|
return `rgba(${rgb}, ${alpha})`;
|
||||||
|
|
@ -70,7 +94,7 @@ class DonutChart extends HTMLElement {
|
||||||
});
|
});
|
||||||
|
|
||||||
const legend = items.map((item, i) => {
|
const legend = items.map((item, i) => {
|
||||||
const pct = Math.round(item.count / total * 100);
|
const pct = Math.round((item.count / total) * 100);
|
||||||
return `<div style="display:flex;align-items:center;gap:0.5rem">
|
return `<div style="display:flex;align-items:center;gap:0.5rem">
|
||||||
<span style="flex-shrink:0;width:0.5rem;height:0.5rem;border-radius:9999px;background:${colorFor(item.count)}"></span>
|
<span style="flex-shrink:0;width:0.5rem;height:0.5rem;border-radius:9999px;background:${colorFor(item.count)}"></span>
|
||||||
<span class="text-xs text-text-secondary" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(item.label)}</span>
|
<span class="text-xs text-text-secondary" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(item.label)}</span>
|
||||||
|
|
@ -80,19 +104,19 @@ class DonutChart extends HTMLElement {
|
||||||
|
|
||||||
this.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;gap:1rem">
|
this.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;gap:1rem">
|
||||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="display:block">
|
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="display:block">
|
||||||
${segments.join('')}
|
${segments.join("")}
|
||||||
${(() => {
|
${(() => {
|
||||||
const icon = DONUT_ICONS[this.dataset.icon];
|
const icon = DONUT_ICONS[this.dataset.icon];
|
||||||
if (!icon) return '';
|
if (!icon) return "";
|
||||||
const s = 24;
|
const s = 24;
|
||||||
return `<svg x="${cx - s / 2}" y="${cy - s / 2}" width="${s}" height="${s}" viewBox="0 0 20 20" class="text-text-muted" fill="currentColor">${icon}</svg>`;
|
return `<svg x="${cx - s / 2}" y="${cy - s / 2}" width="${s}" height="${s}" viewBox="0 0 20 20" class="text-text-muted" fill="currentColor">${icon}</svg>`;
|
||||||
})()}
|
})()}
|
||||||
</svg>
|
</svg>
|
||||||
<div style="display:flex;flex-direction:column;gap:0.375rem;width:100%">
|
<div style="display:flex;flex-direction:column;gap:0.375rem;width:100%">
|
||||||
${legend.join('')}
|
${legend.join("")}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('donut-chart', DonutChart);
|
customElements.define("donut-chart", DonutChart);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
customElements.define("brew-photo-capture", class extends HTMLElement {
|
customElements.define(
|
||||||
|
"brew-photo-capture",
|
||||||
|
class extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
|
|
@ -15,11 +17,15 @@ customElements.define("brew-photo-capture", class extends HTMLElement {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
document.getElementById(this.getAttribute("target-input")).value = reader.result;
|
document.getElementById(this.getAttribute("target-input")).value =
|
||||||
document.getElementById(this.getAttribute("target-form")).requestSubmit();
|
reader.result;
|
||||||
|
document
|
||||||
|
.getElementById(this.getAttribute("target-form"))
|
||||||
|
.requestSubmit();
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
customElements.define("searchable-select", class extends HTMLElement {
|
customElements.define(
|
||||||
|
"searchable-select",
|
||||||
|
class extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
requestAnimationFrame(() => this._setup());
|
requestAnimationFrame(() => this._setup());
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +10,8 @@ customElements.define("searchable-select", class extends HTMLElement {
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
|
|
||||||
const name = this.getAttribute("name");
|
const name = this.getAttribute("name");
|
||||||
const placeholder = this.getAttribute("placeholder") || "Type to search\u2026";
|
const placeholder =
|
||||||
|
this.getAttribute("placeholder") || "Type to search\u2026";
|
||||||
const buttons = [...this.querySelectorAll("button")];
|
const buttons = [...this.querySelectorAll("button")];
|
||||||
|
|
||||||
const hidden = document.createElement("input");
|
const hidden = document.createElement("input");
|
||||||
|
|
@ -21,7 +24,8 @@ customElements.define("searchable-select", class extends HTMLElement {
|
||||||
search.placeholder = placeholder;
|
search.placeholder = placeholder;
|
||||||
|
|
||||||
const options = document.createElement("div");
|
const options = document.createElement("div");
|
||||||
options.className = "hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface";
|
options.className =
|
||||||
|
"hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface";
|
||||||
buttons.forEach((btn) => options.appendChild(btn));
|
buttons.forEach((btn) => options.appendChild(btn));
|
||||||
|
|
||||||
const searchWrap = document.createElement("div");
|
const searchWrap = document.createElement("div");
|
||||||
|
|
@ -33,8 +37,10 @@ customElements.define("searchable-select", class extends HTMLElement {
|
||||||
|
|
||||||
const clear = document.createElement("button");
|
const clear = document.createElement("button");
|
||||||
clear.type = "button";
|
clear.type = "button";
|
||||||
clear.className = "absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition";
|
clear.className =
|
||||||
clear.innerHTML = '<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /></svg>';
|
"absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition";
|
||||||
|
clear.innerHTML =
|
||||||
|
'<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /></svg>';
|
||||||
|
|
||||||
const selectedWrap = document.createElement("div");
|
const selectedWrap = document.createElement("div");
|
||||||
selectedWrap.className = "relative hidden cursor-pointer";
|
selectedWrap.className = "relative hidden cursor-pointer";
|
||||||
|
|
@ -43,9 +49,13 @@ customElements.define("searchable-select", class extends HTMLElement {
|
||||||
|
|
||||||
// Block native change events from child inputs so only our
|
// Block native change events from child inputs so only our
|
||||||
// CustomEvent (which carries evt.detail) reaches data-on:change.
|
// CustomEvent (which carries evt.detail) reaches data-on:change.
|
||||||
this.addEventListener("change", (e) => {
|
this.addEventListener(
|
||||||
|
"change",
|
||||||
|
(e) => {
|
||||||
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
|
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
|
||||||
}, true);
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
this.textContent = "";
|
this.textContent = "";
|
||||||
this.style.display = "block";
|
this.style.display = "block";
|
||||||
|
|
@ -68,7 +78,9 @@ customElements.define("searchable-select", class extends HTMLElement {
|
||||||
const q = search.value.toLowerCase();
|
const q = search.value.toLowerCase();
|
||||||
options.classList.toggle("hidden", !q);
|
options.classList.toggle("hidden", !q);
|
||||||
buttons.forEach((btn) => {
|
buttons.forEach((btn) => {
|
||||||
btn.style.display = btn.textContent.toLowerCase().includes(q) ? "" : "none";
|
btn.style.display = btn.textContent.toLowerCase().includes(q)
|
||||||
|
? ""
|
||||||
|
: "none";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -81,10 +93,16 @@ customElements.define("searchable-select", class extends HTMLElement {
|
||||||
searchWrap.classList.add("hidden");
|
searchWrap.classList.add("hidden");
|
||||||
selectedWrap.classList.remove("hidden");
|
selectedWrap.classList.remove("hidden");
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent("change", {
|
this.dispatchEvent(
|
||||||
detail: { value: btn.value, display: btn.dataset.display, data: { ...btn.dataset } },
|
new CustomEvent("change", {
|
||||||
|
detail: {
|
||||||
|
value: btn.value,
|
||||||
|
display: btn.dataset.display,
|
||||||
|
data: { ...btn.dataset },
|
||||||
|
},
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const doClear = () => {
|
const doClear = () => {
|
||||||
|
|
@ -101,4 +119,5 @@ customElements.define("searchable-select", class extends HTMLElement {
|
||||||
|
|
||||||
selectedWrap.addEventListener("click", doClear);
|
selectedWrap.addEventListener("click", doClear);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -16,7 +16,10 @@ const bufferToBase64url = (buffer) => {
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
binary += String.fromCharCode(bytes[i]);
|
binary += String.fromCharCode(bytes[i]);
|
||||||
}
|
}
|
||||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
return btoa(binary)
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert server challenge options to format navigator.credentials expects
|
// Convert server challenge options to format navigator.credentials expects
|
||||||
|
|
@ -26,7 +29,7 @@ const prepareCreationOptions = (options) => {
|
||||||
publicKey.user.id = base64urlToBuffer(publicKey.user.id);
|
publicKey.user.id = base64urlToBuffer(publicKey.user.id);
|
||||||
if (publicKey.excludeCredentials) {
|
if (publicKey.excludeCredentials) {
|
||||||
publicKey.excludeCredentials = publicKey.excludeCredentials.map((cred) =>
|
publicKey.excludeCredentials = publicKey.excludeCredentials.map((cred) =>
|
||||||
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) })
|
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
|
|
@ -37,7 +40,7 @@ const prepareRequestOptions = (options) => {
|
||||||
publicKey.challenge = base64urlToBuffer(publicKey.challenge);
|
publicKey.challenge = base64urlToBuffer(publicKey.challenge);
|
||||||
if (publicKey.allowCredentials) {
|
if (publicKey.allowCredentials) {
|
||||||
publicKey.allowCredentials = publicKey.allowCredentials.map((cred) =>
|
publicKey.allowCredentials = publicKey.allowCredentials.map((cred) =>
|
||||||
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) })
|
Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
|
|
@ -67,7 +70,9 @@ const serializeAuthenticationCredential = (credential) => {
|
||||||
authenticatorData: bufferToBase64url(response.authenticatorData),
|
authenticatorData: bufferToBase64url(response.authenticatorData),
|
||||||
clientDataJSON: bufferToBase64url(response.clientDataJSON),
|
clientDataJSON: bufferToBase64url(response.clientDataJSON),
|
||||||
signature: bufferToBase64url(response.signature),
|
signature: bufferToBase64url(response.signature),
|
||||||
userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null,
|
userHandle: response.userHandle
|
||||||
|
? bufferToBase64url(response.userHandle)
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -78,13 +83,18 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
|
||||||
const startResponse = await fetch("/api/v1/webauthn/register/start", {
|
const startResponse = await fetch("/api/v1/webauthn/register/start", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ token, display_name: displayName, passkey_name: passkeyName }),
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
display_name: displayName,
|
||||||
|
passkey_name: passkeyName,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!startResponse.ok) {
|
if (!startResponse.ok) {
|
||||||
const status = startResponse.status;
|
const status = startResponse.status;
|
||||||
if (status === 401) throw new Error("Invalid registration token.");
|
if (status === 401) throw new Error("Invalid registration token.");
|
||||||
if (status === 410) throw new Error("Registration token has expired or already been used.");
|
if (status === 410)
|
||||||
|
throw new Error("Registration token has expired or already been used.");
|
||||||
throw new Error(`Failed to start registration (HTTP ${status}).`);
|
throw new Error(`Failed to start registration (HTTP ${status}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +116,9 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!finishResponse.ok) {
|
if (!finishResponse.ok) {
|
||||||
throw new Error(`Failed to complete registration (HTTP ${finishResponse.status}).`);
|
throw new Error(
|
||||||
|
`Failed to complete registration (HTTP ${finishResponse.status}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return finishResponse.json();
|
return finishResponse.json();
|
||||||
|
|
@ -120,7 +132,8 @@ const startPasskeyAuthentication = async (queryParams) => {
|
||||||
|
|
||||||
if (!startResponse.ok) {
|
if (!startResponse.ok) {
|
||||||
const status = startResponse.status;
|
const status = startResponse.status;
|
||||||
if (status === 404) throw new Error("No passkeys registered. Please register first.");
|
if (status === 404)
|
||||||
|
throw new Error("No passkeys registered. Please register first.");
|
||||||
throw new Error(`Failed to start authentication (HTTP ${status}).`);
|
throw new Error(`Failed to start authentication (HTTP ${status}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,7 +170,9 @@ const addPasskey = async (name) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!startResponse.ok) {
|
if (!startResponse.ok) {
|
||||||
throw new Error(`Failed to start passkey registration (HTTP ${startResponse.status}).`);
|
throw new Error(
|
||||||
|
`Failed to start passkey registration (HTTP ${startResponse.status}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { challenge_id, options } = await startResponse.json();
|
const { challenge_id, options } = await startResponse.json();
|
||||||
|
|
@ -178,6 +193,8 @@ const addPasskey = async (name) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!finishResponse.ok) {
|
if (!finishResponse.ok) {
|
||||||
throw new Error(`Failed to complete passkey registration (HTTP ${finishResponse.status}).`);
|
throw new Error(
|
||||||
|
`Failed to complete passkey registration (HTTP ${finishResponse.status}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,44 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="{% block description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}"
|
content="{% block description %}
|
||||||
|
Self-hosted coffee logging — track roasters, roasts, bags, brews, and
|
||||||
|
gear.
|
||||||
|
{% endblock %}"
|
||||||
/>
|
/>
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:site_name" content="Brewlog" />
|
<meta property="og:site_name" content="Brewlog" />
|
||||||
<meta property="og:title" content="{% block og_title %}Brewlog{% endblock %}" />
|
<meta
|
||||||
<meta property="og:description" content="{% block og_description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}" />
|
property="og:title"
|
||||||
|
content="{% block og_title %}Brewlog{% endblock %}"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="{% block og_description %}
|
||||||
|
Self-hosted coffee logging — track roasters, roasts, bags, brews, and
|
||||||
|
gear.
|
||||||
|
{% endblock %}"
|
||||||
|
/>
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<title>{% block title %}Brewlog{% endblock %}</title>
|
<title>{% block title %}Brewlog{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||||
<link rel="icon" id="favicon" type="image/svg+xml" href="/static/favicon-light.svg" />
|
<link
|
||||||
|
rel="icon"
|
||||||
|
id="favicon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="/static/favicon-light.svg"
|
||||||
|
/>
|
||||||
<script>
|
<script>
|
||||||
;(() => {
|
(() => {
|
||||||
const stored = localStorage.getItem("theme")
|
const stored = localStorage.getItem("theme");
|
||||||
const isDark =
|
const isDark =
|
||||||
stored === "dark" || (!stored && matchMedia("(prefers-color-scheme: dark)").matches)
|
stored === "dark" ||
|
||||||
|
(!stored && matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
document.documentElement.setAttribute("data-theme", "dark")
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
document.getElementById("favicon").href = "/static/favicon-dark.svg"
|
document.getElementById("favicon").href = "/static/favicon-dark.svg";
|
||||||
}
|
}
|
||||||
})()
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script
|
<script
|
||||||
type="module"
|
type="module"
|
||||||
|
|
@ -38,95 +56,110 @@
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
<script>
|
<script>
|
||||||
const toggleRow = (event) => {
|
const toggleRow = (event) => {
|
||||||
if (event.target.closest(".card-detail")) return
|
if (event.target.closest(".card-detail")) return;
|
||||||
const row = event.currentTarget
|
const row = event.currentTarget;
|
||||||
row.classList.toggle("expanded")
|
row.classList.toggle("expanded");
|
||||||
row.querySelector(".card-detail")?.classList.toggle("hidden")
|
row.querySelector(".card-detail")?.classList.toggle("hidden");
|
||||||
const r = row.nextElementSibling
|
const r = row.nextElementSibling;
|
||||||
if (r?.classList.contains("detail-row")) r.classList.toggle("hidden")
|
if (r?.classList.contains("detail-row")) r.classList.toggle("hidden");
|
||||||
row.querySelector(".icon-expand")?.classList.toggle("hidden")
|
row.querySelector(".icon-expand")?.classList.toggle("hidden");
|
||||||
row.querySelector(".icon-collapse")?.classList.toggle("hidden")
|
row.querySelector(".icon-collapse")?.classList.toggle("hidden");
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const mobileQuery = window.matchMedia("(max-width: 767px)")
|
const mobileQuery = window.matchMedia("(max-width: 767px)");
|
||||||
if (!mobileQuery.matches) return
|
if (!mobileQuery.matches) return;
|
||||||
|
|
||||||
const setupInfiniteScroll = () => {
|
const setupInfiniteScroll = () => {
|
||||||
document.querySelectorAll("[data-infinite-scroll]").forEach((container) => {
|
document
|
||||||
const sentinel = container.querySelector(".infinite-scroll-sentinel")
|
.querySelectorAll("[data-infinite-scroll]")
|
||||||
if (!sentinel || sentinel.dataset.observed) return
|
.forEach((container) => {
|
||||||
sentinel.dataset.observed = "true"
|
const sentinel = container.querySelector(
|
||||||
|
".infinite-scroll-sentinel",
|
||||||
|
);
|
||||||
|
if (!sentinel || sentinel.dataset.observed) return;
|
||||||
|
sentinel.dataset.observed = "true";
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (!entry.isIntersecting) return
|
if (!entry.isIntersecting) return;
|
||||||
|
|
||||||
const nextUrl = container.getAttribute("data-next-url")
|
const nextUrl = container.getAttribute("data-next-url");
|
||||||
const targetSelector = container.getAttribute("data-target")
|
const targetSelector =
|
||||||
|
container.getAttribute("data-target");
|
||||||
if (!nextUrl || !targetSelector) {
|
if (!nextUrl || !targetSelector) {
|
||||||
observer.disconnect()
|
observer.disconnect();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
observer.disconnect()
|
observer.disconnect();
|
||||||
sentinel.remove()
|
sentinel.remove();
|
||||||
|
|
||||||
fetch(nextUrl, {
|
fetch(nextUrl, {
|
||||||
headers: { "datastar-request": "true" },
|
headers: { "datastar-request": "true" },
|
||||||
})
|
})
|
||||||
.then((res) => res.text())
|
.then((res) => res.text())
|
||||||
.then((html) => {
|
.then((html) => {
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html")
|
const doc = new DOMParser().parseFromString(
|
||||||
const newTarget = doc.querySelector(targetSelector)
|
html,
|
||||||
if (!newTarget) return
|
"text/html",
|
||||||
|
);
|
||||||
|
const newTarget = doc.querySelector(targetSelector);
|
||||||
|
if (!newTarget) return;
|
||||||
|
|
||||||
// Append new rows to existing tbody
|
// Append new rows to existing tbody
|
||||||
const existingTbody = container.querySelector("tbody")
|
const existingTbody = container.querySelector("tbody");
|
||||||
const newTbody = newTarget.querySelector("tbody")
|
const newTbody = newTarget.querySelector("tbody");
|
||||||
if (existingTbody && newTbody) {
|
if (existingTbody && newTbody) {
|
||||||
Array.from(newTbody.children).forEach((row) => {
|
Array.from(newTbody.children).forEach((row) => {
|
||||||
existingTbody.appendChild(document.adoptNode(row))
|
existingTbody.appendChild(document.adoptNode(row));
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are more pages
|
// Check if there are more pages
|
||||||
const newContainer = newTarget.querySelector("[data-infinite-scroll]")
|
const newContainer = newTarget.querySelector(
|
||||||
const newNextUrl = newContainer && newContainer.getAttribute("data-next-url")
|
"[data-infinite-scroll]",
|
||||||
|
);
|
||||||
|
const newNextUrl =
|
||||||
|
newContainer &&
|
||||||
|
newContainer.getAttribute("data-next-url");
|
||||||
if (newNextUrl) {
|
if (newNextUrl) {
|
||||||
container.setAttribute("data-next-url", newNextUrl)
|
container.setAttribute("data-next-url", newNextUrl);
|
||||||
const newSentinel = document.createElement("div")
|
const newSentinel = document.createElement("div");
|
||||||
newSentinel.className = "infinite-scroll-sentinel h-4 md:hidden"
|
newSentinel.className =
|
||||||
newSentinel.setAttribute("aria-hidden", "true")
|
"infinite-scroll-sentinel h-4 md:hidden";
|
||||||
container.appendChild(newSentinel)
|
newSentinel.setAttribute("aria-hidden", "true");
|
||||||
setupInfiniteScroll()
|
container.appendChild(newSentinel);
|
||||||
|
setupInfiniteScroll();
|
||||||
} else {
|
} else {
|
||||||
container.removeAttribute("data-next-url")
|
container.removeAttribute("data-next-url");
|
||||||
container.removeAttribute("data-infinite-scroll")
|
container.removeAttribute("data-infinite-scroll");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
{ rootMargin: "200px" }
|
{ rootMargin: "200px" },
|
||||||
)
|
);
|
||||||
|
|
||||||
observer.observe(sentinel)
|
observer.observe(sentinel);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
setupInfiniteScroll()
|
setupInfiniteScroll();
|
||||||
|
|
||||||
const bodyObserver = new MutationObserver(() => {
|
const bodyObserver = new MutationObserver(() => {
|
||||||
setupInfiniteScroll()
|
setupInfiniteScroll();
|
||||||
})
|
});
|
||||||
bodyObserver.observe(document.body, { childList: true, subtree: true })
|
bodyObserver.observe(document.body, { childList: true, subtree: true });
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-page text-text">
|
<body class="min-h-screen bg-page text-text">
|
||||||
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-4 md:py-10">
|
<main
|
||||||
|
class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-4 md:py-10"
|
||||||
|
>
|
||||||
{% include "partials/nav.html" %} {% block content %}{% endblock %}
|
{% include "partials/nav.html" %} {% block content %}{% endblock %}
|
||||||
<footer
|
<footer
|
||||||
class="mt-8 text-center text-xs tracking-widest text-text-muted small-caps"
|
class="mt-8 text-center text-xs tracking-widest text-text-muted small-caps"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,27 +6,42 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">Admin</h1>
|
<h1 class="text-3xl font-semibold">Admin</h1>
|
||||||
<p class="max-w-2xl text-sm text-text-secondary">Manage passkeys, API tokens, and data.</p>
|
<p class="max-w-2xl text-sm text-text-secondary">
|
||||||
|
Manage passkeys, API tokens, and data.
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Passkeys -->
|
<!-- Passkeys -->
|
||||||
<section data-signals:_show-passkey-form="false" class="rounded-lg border bg-surface p-5">
|
<section
|
||||||
|
data-signals:_show-passkey-form="false"
|
||||||
|
class="rounded-lg border bg-surface p-5"
|
||||||
|
>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-text">Passkeys</h2>
|
<h2 class="text-lg font-semibold text-text">Passkeys</h2>
|
||||||
<p class="mt-1 text-sm text-text-secondary">Passkeys enable secure sign-in without a password.</p>
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
Passkeys enable secure sign-in without a password.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{% for passkey in passkeys %}
|
{% for passkey in passkeys %}
|
||||||
<div class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3">
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
{{ icons::key("h-4 w-4 text-accent shrink-0") }}
|
{{ icons::key("h-4 w-4 text-accent shrink-0") }}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<span class="block text-sm font-semibold text-text">{{ passkey.name }}</span>
|
<span class="block text-sm font-semibold text-text"
|
||||||
|
>{{ passkey.name }}</span
|
||||||
|
>
|
||||||
<span class="block text-xs text-text-muted">
|
<span class="block text-xs text-text-muted">
|
||||||
Added {{ passkey.created_at }}
|
Added {{ passkey.created_at }} ·
|
||||||
· {% if let Some(last_used) = passkey.last_used_at %}Last used {{ last_used }}{% else %}Never used{% endif %}
|
{% if let Some(last_used) = passkey.last_used_at %}
|
||||||
|
Last used {{ last_used }}
|
||||||
|
{% else %}
|
||||||
|
Never used
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -36,8 +51,7 @@
|
||||||
class="shrink-0 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
|
class="shrink-0 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
|
||||||
onclick="deletePasskey({{ passkey.id }}, '{{ passkey.name }}')"
|
onclick="deletePasskey({{ passkey.id }}, '{{ passkey.name }}')"
|
||||||
>
|
>
|
||||||
{{ icons::delete("h-4 w-4") }}
|
{{ icons::delete("h-4 w-4") }} Delete
|
||||||
Delete
|
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -45,13 +59,22 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if passkeys.len() <= 1 %}
|
{% if passkeys.len() <= 1 %}
|
||||||
<p class="text-xs text-text-muted">Add another passkey before removing the only one.</p>
|
<p class="text-xs text-text-muted">
|
||||||
|
Add another passkey before removing the only one.
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Add passkey form -->
|
<!-- Add passkey form -->
|
||||||
<div id="add-passkey-form" class="rounded-md border bg-surface-alt p-4"
|
<div
|
||||||
data-show="$_showPasskeyForm" style="display: none">
|
id="add-passkey-form"
|
||||||
<div id="add-passkey-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"></div>
|
class="rounded-md border bg-surface-alt p-4"
|
||||||
|
data-show="$_showPasskeyForm"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="add-passkey-error"
|
||||||
|
class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"
|
||||||
|
></div>
|
||||||
<div class="flex items-end gap-3">
|
<div class="flex items-end gap-3">
|
||||||
<label class="flex-1 flex flex-col gap-1 text-sm">
|
<label class="flex-1 flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Passkey Name</span>
|
<span class="text-text">Passkey Name</span>
|
||||||
|
|
@ -79,9 +102,12 @@
|
||||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
{{ icons::x_mark("h-4 w-4") }} Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="add-passkey-loading" class="mt-3 hidden flex items-center gap-3 text-sm text-accent">
|
<div
|
||||||
{{ icons::spinner("h-5 w-5") }}
|
id="add-passkey-loading"
|
||||||
Follow the prompts from the browser or device...
|
class="mt-3 hidden flex items-center gap-3 text-sm text-accent"
|
||||||
|
>
|
||||||
|
{{ icons::spinner("h-5 w-5") }} Follow the prompts from the browser or
|
||||||
|
device...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -92,8 +118,7 @@
|
||||||
data-on:click="$_showPasskeyForm = true; setTimeout(() => document.getElementById('passkey-name').focus(), 50)"
|
data-on:click="$_showPasskeyForm = true; setTimeout(() => document.getElementById('passkey-name').focus(), 50)"
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
|
||||||
>
|
>
|
||||||
{{ icons::key("h-4 w-4") }}
|
{{ icons::key("h-4 w-4") }} New Passkey
|
||||||
New Passkey
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,7 +136,9 @@
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-text">API Tokens</h2>
|
<h2 class="text-lg font-semibold text-text">API Tokens</h2>
|
||||||
<p class="mt-1 text-sm text-text-secondary">Use tokens to authenticate the CLI or REST API.</p>
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
Use tokens to authenticate the CLI or REST API.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if tokens.is_empty() %}
|
{% if tokens.is_empty() %}
|
||||||
|
|
@ -119,14 +146,23 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{% for token in tokens %}
|
{% for token in tokens %}
|
||||||
<div id="token-row-{{ token.id }}" class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3">
|
<div
|
||||||
|
id="token-row-{{ token.id }}"
|
||||||
|
class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
{{ icons::terminal("h-4 w-4 text-accent shrink-0") }}
|
{{ icons::terminal("h-4 w-4 text-accent shrink-0") }}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<span class="block text-sm font-semibold text-text">{{ token.name }}</span>
|
<span class="block text-sm font-semibold text-text"
|
||||||
|
>{{ token.name }}</span
|
||||||
|
>
|
||||||
<span class="block text-xs text-text-muted">
|
<span class="block text-xs text-text-muted">
|
||||||
Created {{ token.created_at }}
|
Created {{ token.created_at }} ·
|
||||||
· {% if let Some(last_used) = token.last_used_at %}Last used {{ last_used }}{% else %}Never used{% endif %}
|
{% if let Some(last_used) = token.last_used_at %}
|
||||||
|
Last used {{ last_used }}
|
||||||
|
{% else %}
|
||||||
|
Never used
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -135,8 +171,7 @@
|
||||||
class="shrink-0 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
|
class="shrink-0 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
|
||||||
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
|
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
|
||||||
>
|
>
|
||||||
{{ icons::delete("h-4 w-4") }}
|
{{ icons::delete("h-4 w-4") }} Revoke
|
||||||
Revoke
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -144,10 +179,17 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Create token form -->
|
<!-- Create token form -->
|
||||||
<div class="rounded-md border bg-surface-alt p-4"
|
<div
|
||||||
data-show="$_showTokenForm && !$_tokenCreated" style="display: none">
|
class="rounded-md border bg-surface-alt p-4"
|
||||||
<p data-show="$_tokenError" data-text="$_tokenError" style="display: none"
|
data-show="$_showTokenForm && !$_tokenCreated"
|
||||||
class="mb-3 rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"></p>
|
style="display: none"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
data-show="$_tokenError"
|
||||||
|
data-text="$_tokenError"
|
||||||
|
style="display: none"
|
||||||
|
class="mb-3 rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"
|
||||||
|
></p>
|
||||||
<form
|
<form
|
||||||
data-on:submit="$_creatingToken = true; $_tokenError = ''; @post('/api/v1/tokens', {contentType: 'form'})"
|
data-on:submit="$_creatingToken = true; $_tokenError = ''; @post('/api/v1/tokens', {contentType: 'form'})"
|
||||||
data-on:datastar-fetch="if (!$_creatingToken) return;
|
data-on:datastar-fetch="if (!$_creatingToken) return;
|
||||||
|
|
@ -185,8 +227,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- One-time token display -->
|
<!-- One-time token display -->
|
||||||
<div data-show="$_tokenCreated" style="display: none"
|
<div
|
||||||
class="relative rounded-md border border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-950/40 p-4">
|
data-show="$_tokenCreated"
|
||||||
|
style="display: none"
|
||||||
|
class="relative rounded-md border border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-950/40 p-4"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="window.location.reload()"
|
onclick="window.location.reload()"
|
||||||
|
|
@ -195,10 +240,15 @@
|
||||||
>
|
>
|
||||||
{{ icons::x_mark("h-4 w-4") }}
|
{{ icons::x_mark("h-4 w-4") }}
|
||||||
</button>
|
</button>
|
||||||
<p class="pr-6 text-sm font-medium text-green-800 dark:text-green-300">Token created! Copy it now — it will not be shown again.</p>
|
<p class="pr-6 text-sm font-medium text-green-800 dark:text-green-300">
|
||||||
|
Token created! Copy it now — it will not be shown again.
|
||||||
|
</p>
|
||||||
<div class="mt-3 flex items-center gap-2">
|
<div class="mt-3 flex items-center gap-2">
|
||||||
<code id="token-value" data-text="$_tokenValue"
|
<code
|
||||||
class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-text border border-green-200 dark:border-green-800 break-all select-all"></code>
|
id="token-value"
|
||||||
|
data-text="$_tokenValue"
|
||||||
|
class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-text border border-green-200 dark:border-green-800 break-all select-all"
|
||||||
|
></code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="copyToken(this)"
|
onclick="copyToken(this)"
|
||||||
|
|
@ -216,8 +266,7 @@
|
||||||
data-on:click="$_showTokenForm = true; setTimeout(() => document.getElementById('token-name').focus(), 50)"
|
data-on:click="$_showTokenForm = true; setTimeout(() => document.getElementById('token-name').focus(), 50)"
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
|
||||||
>
|
>
|
||||||
{{ icons::terminal("h-4 w-4") }}
|
{{ icons::terminal("h-4 w-4") }} New Token
|
||||||
New Token
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -228,7 +277,10 @@
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-text">Data</h2>
|
<h2 class="text-lg font-semibold text-text">Data</h2>
|
||||||
<p class="mt-1 text-sm text-text-secondary">Export all coffee data as JSON, restore from a previous backup, or reset to start fresh.</p>
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
Export all coffee data as JSON, restore from a previous backup, or
|
||||||
|
reset to start fresh.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||||
|
|
@ -237,16 +289,14 @@
|
||||||
download
|
download
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
|
||||||
>
|
>
|
||||||
{{ icons::arrow_down_tray("h-4 w-4") }}
|
{{ icons::arrow_down_tray("h-4 w-4") }} New Backup
|
||||||
New Backup
|
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
|
||||||
onclick="document.getElementById('restore-file-input').click()"
|
onclick="document.getElementById('restore-file-input').click()"
|
||||||
>
|
>
|
||||||
{{ icons::arrow_up_tray("h-4 w-4") }}
|
{{ icons::arrow_up_tray("h-4 w-4") }} Restore
|
||||||
Restore
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -258,8 +308,12 @@
|
||||||
data-attr:disabled="$_recomputing"
|
data-attr:disabled="$_recomputing"
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44"
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44"
|
||||||
>
|
>
|
||||||
<span data-show="!$_recomputing">{{ icons::refresh("h-4 w-4") }}</span>
|
<span data-show="!$_recomputing"
|
||||||
<span data-show="$_recomputing" style="display:none">{{ icons::spinner("h-4 w-4") }}</span>
|
>{{ icons::refresh("h-4 w-4") }}</span
|
||||||
|
>
|
||||||
|
<span data-show="$_recomputing" style="display:none"
|
||||||
|
>{{ icons::spinner("h-4 w-4") }}</span
|
||||||
|
>
|
||||||
Recompute Stats
|
Recompute Stats
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -267,16 +321,26 @@
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
|
||||||
onclick="resetDatabase()"
|
onclick="resetDatabase()"
|
||||||
>
|
>
|
||||||
{{ icons::delete("h-4 w-4") }}
|
{{ icons::delete("h-4 w-4") }} Reset
|
||||||
Reset
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="file" id="restore-file-input" accept=".json" class="hidden"
|
<input
|
||||||
onchange="restoreFromFile(this)" />
|
type="file"
|
||||||
|
id="restore-file-input"
|
||||||
|
accept=".json"
|
||||||
|
class="hidden"
|
||||||
|
onchange="restoreFromFile(this)"
|
||||||
|
/>
|
||||||
|
|
||||||
<div id="backup-status" class="hidden rounded-md border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:bg-green-950/40 dark:border-green-800 dark:text-green-300"></div>
|
<div
|
||||||
<div id="backup-error" class="hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"></div>
|
id="backup-status"
|
||||||
|
class="hidden rounded-md border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:bg-green-950/40 dark:border-green-800 dark:text-green-300"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="backup-error"
|
||||||
|
class="hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -289,15 +353,21 @@
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm text-text-muted">Total Cost</span>
|
<span class="block text-sm text-text-muted">Total Cost</span>
|
||||||
<span class="mt-1 block text-lg font-semibold text-text">{{ usage.cost }}</span>
|
<span class="mt-1 block text-lg font-semibold text-text"
|
||||||
|
>{{ usage.cost }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm text-text-muted">API Calls</span>
|
<span class="block text-sm text-text-muted">API Calls</span>
|
||||||
<span class="mt-1 block text-lg font-semibold text-text">{{ usage.calls }}</span>
|
<span class="mt-1 block text-lg font-semibold text-text"
|
||||||
|
>{{ usage.calls }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm text-text-muted">Total Tokens</span>
|
<span class="block text-sm text-text-muted">Total Tokens</span>
|
||||||
<span class="mt-1 block text-lg font-semibold text-text">{{ usage.tokens }}</span>
|
<span class="mt-1 block text-lg font-semibold text-text"
|
||||||
|
>{{ usage.tokens }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -313,9 +383,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<button type="submit" class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44">
|
<button
|
||||||
{{ icons::logout("h-4 w-4") }}
|
type="submit"
|
||||||
Sign Out
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
|
||||||
|
>
|
||||||
|
{{ icons::logout("h-4 w-4") }} Sign Out
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -359,7 +431,9 @@
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(token).then(() => {
|
navigator.clipboard.writeText(token).then(() => {
|
||||||
btn.textContent = "Copied!";
|
btn.textContent = "Copied!";
|
||||||
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
|
setTimeout(() => {
|
||||||
|
btn.textContent = "Copy";
|
||||||
|
}, 2000);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
|
|
@ -377,7 +451,11 @@
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
input.value = "";
|
input.value = "";
|
||||||
|
|
||||||
if (!confirm("Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.")) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,7 +475,9 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 409) {
|
if (response.status === 409) {
|
||||||
throw new Error("Database is not empty. Restore requires an empty database.");
|
throw new Error(
|
||||||
|
"Database is not empty. Restore requires an empty database.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Restore failed (HTTP ${response.status}).`);
|
throw new Error(`Restore failed (HTTP ${response.status}).`);
|
||||||
|
|
@ -406,13 +486,18 @@
|
||||||
status.textContent = "Backup restored successfully.";
|
status.textContent = "Backup restored successfully.";
|
||||||
status.classList.remove("hidden");
|
status.classList.remove("hidden");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.textContent = err instanceof SyntaxError ? "Invalid JSON file." : err.message;
|
error.textContent =
|
||||||
|
err instanceof SyntaxError ? "Invalid JSON file." : err.message;
|
||||||
error.classList.remove("hidden");
|
error.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetDatabase = async () => {
|
const resetDatabase = async () => {
|
||||||
if (!confirm("Reset all coffee data?\n\nThis will permanently delete all roasters, roasts, bags, gear, brews, cafes, cups, and timeline events.\n\nThis cannot be undone.")) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Reset all coffee data?\n\nThis will permanently delete all roasters, roasts, bags, gear, brews, cafes, cups, and timeline events.\n\nThis cannot be undone.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,13 +511,16 @@
|
||||||
error.classList.add("hidden");
|
error.classList.add("hidden");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/v1/backup/reset", { method: "POST" });
|
const response = await fetch("/api/v1/backup/reset", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Reset failed (HTTP ${response.status}).`);
|
throw new Error(`Reset failed (HTTP ${response.status}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
status.textContent = "Database reset successfully. All coffee data has been deleted.";
|
status.textContent =
|
||||||
|
"Database reset successfully. All coffee data has been deleted.";
|
||||||
status.classList.remove("hidden");
|
status.classList.remove("hidden");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.textContent = err.message;
|
error.textContent = err.message;
|
||||||
|
|
@ -444,7 +532,9 @@
|
||||||
if (!confirm(`Delete passkey "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Delete passkey "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/passkeys/${id}`, { method: "DELETE" });
|
const response = await fetch(`/api/v1/passkeys/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else if (response.status === 409) {
|
} else if (response.status === 409) {
|
||||||
|
|
@ -461,7 +551,9 @@
|
||||||
if (!confirm(`Revoke token "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Revoke token "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/tokens/${id}/revoke`, { method: "POST" });
|
const response = await fetch(`/api/v1/tokens/${id}/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const row = document.getElementById(`token-row-${id}`);
|
const row = document.getElementById(`token-row-${id}`);
|
||||||
if (row) row.remove();
|
if (row) row.remove();
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,25 @@
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %}
|
{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %}
|
||||||
{% block description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %}
|
{% block description %}
|
||||||
|
{{ bag.roast_name }}
|
||||||
|
by {{ bag.roaster_name }} — {{ bag.amount }} bag.
|
||||||
|
{% endblock %}
|
||||||
{% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %}
|
{% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %}
|
{% block og_description %}
|
||||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
{{ bag.roast_name }}
|
||||||
|
by {{ bag.roaster_name }} — {{ bag.amount }} bag.
|
||||||
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">{{ bag.roast_name }}</h1>
|
<h1 class="text-3xl font-semibold">{{ bag.roast_name }}</h1>
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{{ bag.roaster_name }} · {{ bag.amount }} · Opened {{ bag.created_date }}
|
{{ bag.roaster_name }} · {{ bag.amount }} · Opened
|
||||||
|
{{ bag.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -41,9 +50,14 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<div class="h-2 rounded-full bg-surface-alt overflow-hidden">
|
<div class="h-2 rounded-full bg-surface-alt overflow-hidden">
|
||||||
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div>
|
<div
|
||||||
|
class="h-full rounded-full bg-accent"
|
||||||
|
style="width: {{ bag.used_percent }}%"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">{{ bag.remaining }} / {{ bag.amount }}</p>
|
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">
|
||||||
|
{{ bag.remaining }} / {{ bag.amount }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
@ -71,17 +85,23 @@
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
{# ── Actions ── #}
|
{# ── Actions ── #}
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<div class="rounded-lg border bg-surface p-5 md:col-span-2 flex items-center gap-2">
|
<div
|
||||||
|
class="rounded-lg border bg-surface p-5 md:col-span-2 flex items-center gap-2"
|
||||||
|
>
|
||||||
{% if !bag.closed %}
|
{% if !bag.closed %}
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
|
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
|
||||||
data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&remaining=0')">
|
data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&remaining=0')"
|
||||||
|
>
|
||||||
{{ icons::x_mark("h-4 w-4") }} Close Bag
|
{{ icons::x_mark("h-4 w-4") }} Close Bag
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
||||||
data-on:click="confirm('Delete this bag? This cannot be undone.') && @delete('/api/v1/bags/{{ bag.id }}')">
|
data-on:click="confirm('Delete this bag? This cannot be undone.') && @delete('/api/v1/bags/{{ bag.id }}')"
|
||||||
|
>
|
||||||
{{ icons::delete("h-4 w-4") }} Delete
|
{{ icons::delete("h-4 w-4") }} Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,27 @@
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
|
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
|
||||||
{% block description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
|
{% block description %}
|
||||||
|
{{ brew.roast_name }}
|
||||||
|
by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee,
|
||||||
|
{{ brew.water_volume }} water.
|
||||||
|
{% endblock %}
|
||||||
{% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
|
{% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
|
{% block og_description %}
|
||||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
{{ brew.roast_name }}
|
||||||
|
by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee,
|
||||||
|
{{ brew.water_volume }} water.
|
||||||
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">{{ brew.roast_name }}</h1>
|
<h1 class="text-3xl font-semibold">{{ brew.roast_name }}</h1>
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }}
|
{{ brew.roaster_name }} · Brewed {{ brew.created_date }} at
|
||||||
|
{{ brew.created_time }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -86,9 +97,11 @@
|
||||||
|
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
||||||
data-on:click="confirm('Delete this brew? This cannot be undone.') && @delete('/api/v1/brews/{{ brew.id }}')">
|
data-on:click="confirm('Delete this brew? This cannot be undone.') && @delete('/api/v1/brews/{{ brew.id }}')"
|
||||||
|
>
|
||||||
{{ icons::delete("h-4 w-4") }} Delete
|
{{ icons::delete("h-4 w-4") }} Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,20 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · {{ cafe.name }}{% endblock %}
|
{% block title %}Brewlog · {{ cafe.name }}{% endblock %}
|
||||||
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
|
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}{{ cafe.name }} — {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }}{% endblock %}
|
{% block og_description %}
|
||||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
{{ cafe.name }}
|
||||||
|
— {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">{{ cafe.name }}</h1>
|
<h1 class="text-3xl font-semibold">{{ cafe.name }}</h1>
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added {{ cafe.created_date }}
|
{{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added
|
||||||
|
{{ cafe.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -33,7 +39,13 @@
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-text-muted">Website</dt>
|
<dt class="text-text-muted">Website</dt>
|
||||||
<dd class="font-medium">
|
<dd class="font-medium">
|
||||||
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a>
|
<a
|
||||||
|
href="{{ url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
class="text-accent hover:text-accent-hover transition"
|
||||||
|
>Visit Website</a
|
||||||
|
>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -45,9 +57,11 @@
|
||||||
|
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
||||||
data-on:click="confirm('Delete this cafe? This cannot be undone.') && @delete('/api/v1/cafes/{{ cafe.id }}')">
|
data-on:click="confirm('Delete this cafe? This cannot be undone.') && @delete('/api/v1/cafes/{{ cafe.id }}')"
|
||||||
|
>
|
||||||
{{ icons::delete("h-4 w-4") }} Delete
|
{{ icons::delete("h-4 w-4") }} Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import
|
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||||
"partials/scan_input.html" as scan %} {% import "partials/location_search.html" as location %} {%
|
{%
|
||||||
block title %}Brewlog · Check In{% endblock %} {% block head %}
|
import
|
||||||
|
"partials/scan_input.html" as scan
|
||||||
|
%}
|
||||||
|
{% import "partials/location_search.html" as location %}
|
||||||
|
{% block title %}
|
||||||
|
Brewlog · Check In
|
||||||
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
<script>
|
<script>
|
||||||
{{ location::location_search_js() }}
|
{
|
||||||
|
{
|
||||||
|
location::location_search_js();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %} {% block content %}
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
<section
|
<section
|
||||||
id="checkin-root"
|
id="checkin-root"
|
||||||
data-signals:_step="1"
|
data-signals:_step="1"
|
||||||
|
|
@ -33,13 +45,18 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
>
|
>
|
||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">Check In</h1>
|
<h1 class="text-3xl font-semibold">Check In</h1>
|
||||||
<p class="max-w-2xl text-sm text-text-secondary">Record a cup of coffee at a cafe.</p>
|
<p class="max-w-2xl text-sm text-text-secondary">
|
||||||
|
Record a cup of coffee at a cafe.
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Step progress -->
|
<!-- Step progress -->
|
||||||
<div class="relative mx-auto mt-6" style="width: 80%">
|
<div class="relative mx-auto mt-6" style="width: 80%">
|
||||||
<!-- Background track (continuous line through dot centers) -->
|
<!-- Background track (continuous line through dot centers) -->
|
||||||
<div class="absolute bg-border" style="height: 3px; top: 8px; left: 10px; right: 10px"></div>
|
<div
|
||||||
|
class="absolute bg-border"
|
||||||
|
style="height: 3px; top: 8px; left: 10px; right: 10px"
|
||||||
|
></div>
|
||||||
<!-- Accent overlay: first segment -->
|
<!-- Accent overlay: first segment -->
|
||||||
<div
|
<div
|
||||||
class="absolute bg-accent"
|
class="absolute bg-accent"
|
||||||
|
|
@ -153,16 +170,27 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
<!-- Step 1: Cafe selection -->
|
<!-- Step 1: Cafe selection -->
|
||||||
<div class="mt-4" data-show="$_step === 1" style="display: none">
|
<div class="mt-4" data-show="$_step === 1" style="display: none">
|
||||||
<div class="rounded-lg border bg-surface p-5">
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
<div data-show="!$_reviewingCafe">{{ location::location_search("$_error") }}</div>
|
<div data-show="!$_reviewingCafe">
|
||||||
|
{{ location::location_search("$_error") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Review form for new cafe from Foursquare -->
|
<!-- Review form for new cafe from Foursquare -->
|
||||||
<div data-show="$_reviewingCafe" style="display: none">
|
<div data-show="$_reviewingCafe" style="display: none">
|
||||||
<h3 class="text-base font-semibold text-text mb-1">Confirm cafe details</h3>
|
<h3 class="text-base font-semibold text-text mb-1">
|
||||||
<p class="text-sm text-text-secondary mb-3">Review and edit the details before saving.</p>
|
Confirm cafe details
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-text-secondary mb-3">
|
||||||
|
Review and edit the details before saving.
|
||||||
|
</p>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Name *</span>
|
<span class="text-text">Name *</span>
|
||||||
<input type="text" data-bind:_cafe-name required class="input-field" />
|
<input
|
||||||
|
type="text"
|
||||||
|
data-bind:_cafe-name
|
||||||
|
required
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">City</span>
|
<span class="text-text">City</span>
|
||||||
|
|
@ -203,7 +231,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
<!-- Saved cafes -->
|
<!-- Saved cafes -->
|
||||||
{% if !cafe_options.is_empty() %}
|
{% if !cafe_options.is_empty() %}
|
||||||
<div class="mt-3 border-t pt-3" data-show="!$_reviewingCafe">
|
<div class="mt-3 border-t pt-3" data-show="!$_reviewingCafe">
|
||||||
<p class="mb-2 text-sm text-text-secondary">Or choose a saved cafe:</p>
|
<p class="mb-2 text-sm text-text-secondary">
|
||||||
|
Or choose a saved cafe:
|
||||||
|
</p>
|
||||||
<searchable-select
|
<searchable-select
|
||||||
name="saved_cafe_id"
|
name="saved_cafe_id"
|
||||||
placeholder="Type to search saved cafes…"
|
placeholder="Type to search saved cafes…"
|
||||||
|
|
@ -218,7 +248,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
|
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
|
||||||
>
|
>
|
||||||
<span class="font-medium text-text">{{ cafe.name }}</span>
|
<span class="font-medium text-text">{{ cafe.name }}</span>
|
||||||
<span class="ml-2 text-xs text-text-muted">{{ cafe.city }}</span>
|
<span class="ml-2 text-xs text-text-muted"
|
||||||
|
>{{ cafe.city }}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</searchable-select>
|
</searchable-select>
|
||||||
|
|
@ -234,7 +266,8 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
|
|
||||||
<div data-show="!$_scanSuccess" class="mb-4">
|
<div data-show="!$_scanSuccess" class="mb-4">
|
||||||
<p class="text-sm text-text-secondary mb-3" data-show="!$_extracting">
|
<p class="text-sm text-text-secondary mb-3" data-show="!$_extracting">
|
||||||
Scan a bag to identify the coffee, or select from existing roasts below.
|
Scan a bag to identify the coffee, or select from existing roasts
|
||||||
|
below.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
|
|
@ -242,8 +275,10 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
||||||
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Scan failed. Please try again.' }"
|
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Scan failed. Please try again.' }"
|
||||||
>
|
>
|
||||||
{{ scan::scan_input("checkin-scan-form", "checkin-image", "$_extracting",
|
{{
|
||||||
"$_extractError", "Describe a coffee bag…", "Scanning…") }}
|
scan::scan_input("checkin-scan-form", "checkin-image", "$_extracting",
|
||||||
|
"$_extractError", "Describe a coffee bag…", "Scanning…")
|
||||||
|
}}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -257,7 +292,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
|
|
||||||
{% if !roast_options.is_empty() %}
|
{% if !roast_options.is_empty() %}
|
||||||
<div class="border-t pt-3">
|
<div class="border-t pt-3">
|
||||||
<p class="text-sm text-text-secondary mb-2">Or select an existing roast:</p>
|
<p class="text-sm text-text-secondary mb-2">
|
||||||
|
Or select an existing roast:
|
||||||
|
</p>
|
||||||
<searchable-select
|
<searchable-select
|
||||||
name="roast_id"
|
name="roast_id"
|
||||||
placeholder="Type to search existing roasts…"
|
placeholder="Type to search existing roasts…"
|
||||||
|
|
@ -272,7 +309,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
|
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
|
||||||
>
|
>
|
||||||
<span class="font-medium text-text">{{ roast.name }}</span>
|
<span class="font-medium text-text">{{ roast.name }}</span>
|
||||||
<span class="ml-2 text-xs text-text-muted">{{ roast.roaster_name }}</span>
|
<span class="ml-2 text-xs text-text-muted"
|
||||||
|
>{{ roast.roaster_name }}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</searchable-select>
|
</searchable-select>
|
||||||
|
|
@ -284,7 +323,9 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
<!-- Step 3: Review + submit -->
|
<!-- Step 3: Review + submit -->
|
||||||
<div class="mt-4" data-show="$_step === 3" style="display: none">
|
<div class="mt-4" data-show="$_step === 3" style="display: none">
|
||||||
<div class="rounded-lg border bg-surface p-5">
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
<h3 class="text-base font-semibold text-text mb-4">Review & Submit</h3>
|
<h3 class="text-base font-semibold text-text mb-4">
|
||||||
|
Review & Submit
|
||||||
|
</h3>
|
||||||
<dl class="flex flex-col gap-3 text-sm mb-6">
|
<dl class="flex flex-col gap-3 text-sm mb-6">
|
||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<dt class="font-medium text-text-muted">Cafe</dt>
|
<dt class="font-medium text-text-muted">Cafe</dt>
|
||||||
|
|
@ -309,10 +350,18 @@ block title %}Brewlog · Check In{% endblock %} {% block head %}
|
||||||
<input type="hidden" name="cafe_id" data-attr:value="$_cafeId" />
|
<input type="hidden" name="cafe_id" data-attr:value="$_cafeId" />
|
||||||
<input type="hidden" name="cafe_name" data-attr:value="$_cafeName" />
|
<input type="hidden" name="cafe_name" data-attr:value="$_cafeName" />
|
||||||
<input type="hidden" name="cafe_city" data-attr:value="$_cafeCity" />
|
<input type="hidden" name="cafe_city" data-attr:value="$_cafeCity" />
|
||||||
<input type="hidden" name="cafe_country" data-attr:value="$_cafeCountry" />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="cafe_country"
|
||||||
|
data-attr:value="$_cafeCountry"
|
||||||
|
/>
|
||||||
<input type="hidden" name="cafe_lat" data-attr:value="$_cafeLat" />
|
<input type="hidden" name="cafe_lat" data-attr:value="$_cafeLat" />
|
||||||
<input type="hidden" name="cafe_lng" data-attr:value="$_cafeLng" />
|
<input type="hidden" name="cafe_lng" data-attr:value="$_cafeLng" />
|
||||||
<input type="hidden" name="cafe_website" data-attr:value="$_cafeWebsite" />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="cafe_website"
|
||||||
|
data-attr:value="$_cafeWebsite"
|
||||||
|
/>
|
||||||
<input type="hidden" name="roast_id" data-attr:value="$_roastId" />
|
<input type="hidden" name="roast_id" data-attr:value="$_roastId" />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,19 @@
|
||||||
{{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }}
|
{{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }}
|
||||||
<h1 class="text-2xl font-semibold text-accent">CLI Authenticated</h1>
|
<h1 class="text-2xl font-semibold text-accent">CLI Authenticated</h1>
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
Your CLI has been authenticated successfully. You can close this window and return to the terminal.
|
Your CLI has been authenticated successfully. You can close this
|
||||||
|
window and return to the terminal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else if error.is_some() %}
|
{% else if error.is_some() %}
|
||||||
<div class="flex flex-col items-center text-center gap-3">
|
<div class="flex flex-col items-center text-center gap-3">
|
||||||
{{ icons::x_circle("h-10 w-10 text-red-600 dark:text-red-400") }}
|
{{ icons::x_circle("h-10 w-10 text-red-600 dark:text-red-400") }}
|
||||||
<h1 class="text-2xl font-semibold text-accent">Authentication Failed</h1>
|
<h1 class="text-2xl font-semibold text-accent">
|
||||||
<div class="w-full rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300">
|
Authentication Failed
|
||||||
|
</h1>
|
||||||
|
<div
|
||||||
|
class="w-full rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800 dark:bg-red-950/40 dark:border-red-800 dark:text-red-300"
|
||||||
|
>
|
||||||
{{ error.as_ref().unwrap() }}
|
{{ error.as_ref().unwrap() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,25 @@
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
|
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
|
||||||
{% block description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
|
{% block description %}
|
||||||
|
{{ cup.roast_name }}
|
||||||
|
by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.
|
||||||
|
{% endblock %}
|
||||||
{% block og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %}
|
{% block og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
|
{% block og_description %}
|
||||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
{{ cup.roast_name }}
|
||||||
|
by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.
|
||||||
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">{{ cup.roast_name }}</h1>
|
<h1 class="text-3xl font-semibold">{{ cup.roast_name }}</h1>
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }}
|
{{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} ·
|
||||||
|
{{ cup.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -47,7 +56,13 @@
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-text-muted">Website</dt>
|
<dt class="text-text-muted">Website</dt>
|
||||||
<dd class="font-medium">
|
<dd class="font-medium">
|
||||||
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a>
|
<a
|
||||||
|
href="{{ url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
class="text-accent hover:text-accent-hover transition"
|
||||||
|
>Visit Website</a
|
||||||
|
>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -57,9 +72,11 @@
|
||||||
|
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
||||||
data-on:click="confirm('Delete this cup? This cannot be undone.') && @delete('/api/v1/cups/{{ cup.id }}')">
|
data-on:click="confirm('Delete this cup? This cannot be undone.') && @delete('/api/v1/cups/{{ cup.id }}')"
|
||||||
|
>
|
||||||
{{ icons::delete("h-4 w-4") }} Delete
|
{{ icons::delete("h-4 w-4") }} Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Data{% endblock %}
|
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||||
|
{% block title %}Brewlog · Data{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">Data</h1>
|
<h1 class="text-3xl font-semibold">Data</h1>
|
||||||
|
|
@ -19,9 +20,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="data-content" class="data-page-content">
|
<div id="data-content" class="data-page-content">{{ content|safe }}</div>
|
||||||
{{ content|safe }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="detail-panel"></div>
|
<div id="detail-panel"></div>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,14 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
|
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
|
||||||
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
|
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}{{ gear.make }} {{ gear.model }} — {{ gear.category_label }}{% endblock %}
|
{% block og_description %}
|
||||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
{{ gear.make }}
|
||||||
|
{{ gear.model }}
|
||||||
|
— {{ gear.category_label }}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|
@ -34,9 +40,11 @@
|
||||||
|
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
||||||
data-on:click="confirm('Delete this gear? This cannot be undone.') && @delete('/api/v1/gear/{{ gear.id }}')">
|
data-on:click="confirm('Delete this gear? This cannot be undone.') && @delete('/api/v1/gear/{{ gear.id }}')"
|
||||||
|
>
|
||||||
{{ icons::delete("h-4 w-4") }} Delete
|
{{ icons::delete("h-4 w-4") }} Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import
|
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %}
|
||||||
"partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %}
|
{%
|
||||||
|
import
|
||||||
|
"partials/brew_card.html" as brew_card
|
||||||
|
%}
|
||||||
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% block title %}Brewlog{% endblock %}
|
||||||
|
{% block head %}
|
||||||
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
{% endblock %} {% block content %}
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
<!-- Scan Bag -->
|
<!-- Scan Bag -->
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<section
|
<section
|
||||||
|
|
@ -28,19 +35,29 @@
|
||||||
<!-- Quick actions (shown when not yet extracted) -->
|
<!-- Quick actions (shown when not yet extracted) -->
|
||||||
<div data-show="!$_scanExtracted">
|
<div data-show="!$_scanExtracted">
|
||||||
<div data-show="!$_extracting" class="grid grid-cols-3 gap-4">
|
<div data-show="!$_extracting" class="grid grid-cols-3 gap-4">
|
||||||
<input type="hidden" name="image" id="scan-image" form="scan-extract-form" />
|
<input
|
||||||
<form id="scan-extract-form"
|
type="hidden"
|
||||||
|
name="image"
|
||||||
|
id="scan-image"
|
||||||
|
form="scan-extract-form"
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
id="scan-extract-form"
|
||||||
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
|
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
|
||||||
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
|
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
|
||||||
class="hidden"></form>
|
class="hidden"
|
||||||
<brew-photo-capture target-input="scan-image" target-form="scan-extract-form"
|
></form>
|
||||||
|
<brew-photo-capture
|
||||||
|
target-input="scan-image"
|
||||||
|
target-form="scan-extract-form"
|
||||||
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40 cursor-pointer"
|
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40 cursor-pointer"
|
||||||
aria-label="Scan Bag"
|
aria-label="Scan Bag"
|
||||||
>
|
>
|
||||||
{{ icons::camera("h-5 w-5") }}
|
{{ icons::camera("h-5 w-5") }}
|
||||||
<span class="text-sm font-medium">Scan</span>
|
<span class="text-sm font-medium">Scan</span>
|
||||||
</brew-photo-capture>
|
</brew-photo-capture>
|
||||||
<a href="/check-in"
|
<a
|
||||||
|
href="/check-in"
|
||||||
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40"
|
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40"
|
||||||
title="Check in at a cafe"
|
title="Check in at a cafe"
|
||||||
aria-label="Check in at a cafe"
|
aria-label="Check in at a cafe"
|
||||||
|
|
@ -48,7 +65,8 @@
|
||||||
{{ icons::location("h-5 w-5") }}
|
{{ icons::location("h-5 w-5") }}
|
||||||
<span class="text-sm font-medium">Check In</span>
|
<span class="text-sm font-medium">Check In</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/add"
|
<a
|
||||||
|
href="/add"
|
||||||
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40"
|
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40"
|
||||||
title="Add manually"
|
title="Add manually"
|
||||||
aria-label="Add manually"
|
aria-label="Add manually"
|
||||||
|
|
@ -57,11 +75,19 @@
|
||||||
<span class="text-sm font-medium">Add</span>
|
<span class="text-sm font-medium">Add</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div data-show="$_extracting" style="display:none" class="flex items-center justify-center gap-3 text-sm text-accent">
|
<div
|
||||||
{{ icons::spinner("h-5 w-5") }}
|
data-show="$_extracting"
|
||||||
Waiting for response…
|
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>
|
</div>
|
||||||
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600 text-center"></p>
|
<p
|
||||||
|
data-show="$_extractError"
|
||||||
|
data-text="$_extractError"
|
||||||
|
style="display:none"
|
||||||
|
class="mt-2 text-sm text-red-600 text-center"
|
||||||
|
></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form: pre-filled roaster + roast (shown after extraction) -->
|
<!-- Form: pre-filled roaster + roast (shown after extraction) -->
|
||||||
|
|
@ -81,33 +107,49 @@
|
||||||
<section data-signals:_brewing="false">
|
<section data-signals:_brewing="false">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h2 class="text-lg font-semibold text-text">Recent Brews</h2>
|
<h2 class="text-lg font-semibold text-text">Recent Brews</h2>
|
||||||
<a href="/data?type=brews" class="text-sm text-accent hover:text-accent-hover font-medium"
|
<a
|
||||||
|
href="/data?type=brews"
|
||||||
|
class="text-sm text-accent hover:text-accent-hover font-medium"
|
||||||
>View all brews →</a
|
>View all brews →</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{% if !recent_brews.is_empty() %}
|
{% if !recent_brews.is_empty() %}
|
||||||
<chip-scroll class="relative block">
|
<chip-scroll class="relative block">
|
||||||
<button type="button" aria-label="Scroll left"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll left"
|
||||||
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-left
|
data-scroll-left
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_left("h-5 w-5") }}
|
{{ icons::chevron_left("h-5 w-5") }}
|
||||||
</button>
|
</button>
|
||||||
<div class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide" data-chip-scroll>
|
<div
|
||||||
|
class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide"
|
||||||
|
data-chip-scroll
|
||||||
|
>
|
||||||
{% for brew in recent_brews %}{{ brew_card::card(brew, is_authenticated) }}{% endfor %}
|
{% for brew in recent_brews %}{{ brew_card::card(brew, is_authenticated) }}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="Scroll right"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll right"
|
||||||
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-right
|
data-scroll-right
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</button>
|
</button>
|
||||||
</chip-scroll>
|
</chip-scroll>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">
|
<div
|
||||||
|
class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
{% for _ in 0..6 %}
|
{% for _ in 0..6 %}
|
||||||
<div class="w-[45vw] md:w-[200px] h-[279px] shrink-0 rounded-lg border bg-surface p-4 flex flex-col">
|
<div
|
||||||
|
class="w-[45vw] md:w-[200px] h-[279px] shrink-0 rounded-lg border bg-surface p-4 flex flex-col"
|
||||||
|
>
|
||||||
<div class="h-[4.5rem]">
|
<div class="h-[4.5rem]">
|
||||||
<div class="h-4 w-3/4 rounded bg-surface-alt"></div>
|
<div class="h-4 w-3/4 rounded bg-surface-alt"></div>
|
||||||
<div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div>
|
<div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div>
|
||||||
|
|
@ -123,7 +165,9 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
<span class="text-lg font-semibold text-text-muted">No brews yet</span>
|
<span class="text-lg font-semibold text-text-muted"
|
||||||
|
>No brews yet</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -133,16 +177,21 @@
|
||||||
<section id="open-bags-section">
|
<section id="open-bags-section">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h2 class="text-lg font-semibold text-text">Open Bags</h2>
|
<h2 class="text-lg font-semibold text-text">Open Bags</h2>
|
||||||
<a href="/data?type=bags" class="text-sm text-accent hover:text-accent-hover font-medium"
|
<a
|
||||||
|
href="/data?type=bags"
|
||||||
|
class="text-sm text-accent hover:text-accent-hover font-medium"
|
||||||
>View all bags →</a
|
>View all bags →</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{% if !open_bags.is_empty() %}
|
{% if !open_bags.is_empty() %}
|
||||||
<chip-scroll class="relative block">
|
<chip-scroll class="relative block">
|
||||||
<button type="button" aria-label="Scroll left"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll left"
|
||||||
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-left
|
data-scroll-left
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_left("h-5 w-5") }}
|
{{ icons::chevron_left("h-5 w-5") }}
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
|
|
@ -152,18 +201,26 @@
|
||||||
>
|
>
|
||||||
{% for bag in open_bags %}{{ bag_card::card(bag, is_authenticated) }}{% endfor %}
|
{% for bag in open_bags %}{{ bag_card::card(bag, is_authenticated) }}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="Scroll right"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll right"
|
||||||
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-right
|
data-scroll-right
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</button>
|
</button>
|
||||||
</chip-scroll>
|
</chip-scroll>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">
|
<div
|
||||||
|
class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
{% for _ in 0..6 %}
|
{% for _ in 0..6 %}
|
||||||
<div class="w-[45vw] md:w-[200px] h-[200px] shrink-0 rounded-lg border bg-surface p-4 flex flex-col">
|
<div
|
||||||
|
class="w-[45vw] md:w-[200px] h-[200px] shrink-0 rounded-lg border bg-surface p-4 flex flex-col"
|
||||||
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="h-4 w-3/4 rounded bg-surface-alt"></div>
|
<div class="h-4 w-3/4 rounded bg-surface-alt"></div>
|
||||||
<div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div>
|
<div class="mt-2 h-3 w-1/2 rounded bg-surface-alt"></div>
|
||||||
|
|
@ -180,7 +237,9 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
<span class="text-lg font-semibold text-text-muted">No open bags</span>
|
<span class="text-lg font-semibold text-text-muted"
|
||||||
|
>No open bags</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -190,21 +249,28 @@
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h2 class="text-lg font-semibold text-text">Recent Activity</h2>
|
<h2 class="text-lg font-semibold text-text">Recent Activity</h2>
|
||||||
<a href="/timeline" class="text-sm text-accent hover:text-accent-hover font-medium"
|
<a
|
||||||
|
href="/timeline"
|
||||||
|
class="text-sm text-accent hover:text-accent-hover font-medium"
|
||||||
>View full timeline →</a
|
>View full timeline →</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{% if !recent_events.is_empty() %}
|
{% if !recent_events.is_empty() %}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{% for event in recent_events %}
|
{% for event in recent_events %}
|
||||||
<a href="{{ event.link }}" class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3 transition hover:border-accent/40">
|
<a
|
||||||
|
href="{{ event.link }}"
|
||||||
|
class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3 transition hover:border-accent/40"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<span class="text-text-muted shrink-0">
|
<span class="text-text-muted shrink-0">
|
||||||
{% if event.entity_type == "brew" %}{{ icons::beaker("h-5 w-5") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-5 w-5") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-5 w-5") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-5 w-5") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-5 w-5") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-5 w-5") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-5 w-5") }}{% else %}{{ icons::beaker("h-5 w-5") }}{% endif %}
|
{% if event.entity_type == "brew" %}{{ icons::beaker("h-5 w-5") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-5 w-5") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-5 w-5") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-5 w-5") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-5 w-5") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-5 w-5") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-5 w-5") }}{% else %}{{ icons::beaker("h-5 w-5") }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<p class="min-w-0 truncate text-sm">
|
<p class="min-w-0 truncate text-sm">
|
||||||
<span class="font-medium text-text">{{ event.title }}</span>{% if let Some(sub) = event.subtitle %}
|
<span class="font-medium text-text">{{ event.title }}</span
|
||||||
<span class="text-xs text-text-muted">· {{ sub }}</span>{% endif %}
|
>{% if let Some(sub) = event.subtitle %}
|
||||||
|
<span class="text-xs text-text-muted">· {{ sub }}</span>
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<time class="text-xs text-text-muted whitespace-nowrap shrink-0"
|
<time class="text-xs text-text-muted whitespace-nowrap shrink-0"
|
||||||
|
|
@ -215,9 +281,14 @@
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="space-y-2 blur-[2px] select-none pointer-events-none" aria-hidden="true">
|
<div
|
||||||
|
class="space-y-2 blur-[2px] select-none pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
{% for _ in 0..3 %}
|
{% for _ in 0..3 %}
|
||||||
<div class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3">
|
<div
|
||||||
|
class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="h-5 w-5 shrink-0 rounded-full bg-surface-alt"></div>
|
<div class="h-5 w-5 shrink-0 rounded-full bg-surface-alt"></div>
|
||||||
<div class="h-4 w-40 rounded bg-surface-alt"></div>
|
<div class="h-4 w-40 rounded bg-surface-alt"></div>
|
||||||
|
|
@ -227,7 +298,9 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
<span class="text-lg font-semibold text-text-muted">No activity yet</span>
|
<span class="text-lg font-semibold text-text-muted"
|
||||||
|
>No activity yet</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -237,39 +310,62 @@
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center justify-between mb-5">
|
<div class="flex items-center justify-between mb-5">
|
||||||
<h2 class="text-lg font-semibold text-text">Stats</h2>
|
<h2 class="text-lg font-semibold text-text">Stats</h2>
|
||||||
<a href="/stats" class="text-sm text-accent hover:text-accent-hover font-medium"
|
<a
|
||||||
|
href="/stats"
|
||||||
|
class="text-sm text-accent hover:text-accent-hover font-medium"
|
||||||
>View all stats →</a
|
>View all stats →</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{% if !stats.is_empty() && !stat_cards.is_empty() %}
|
{% if !stats.is_empty() && !stat_cards.is_empty() %}
|
||||||
<chip-scroll class="relative block">
|
<chip-scroll class="relative block">
|
||||||
<button type="button" aria-label="Scroll left"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll left"
|
||||||
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-left
|
data-scroll-left
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_left("h-5 w-5") }}
|
{{ icons::chevron_left("h-5 w-5") }}
|
||||||
</button>
|
</button>
|
||||||
<div class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll>
|
<div
|
||||||
|
class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-x-auto scroll-smooth scrollbar-hide"
|
||||||
|
data-chip-scroll
|
||||||
|
>
|
||||||
{% for card in stat_cards %}
|
{% for card in stat_cards %}
|
||||||
<a href="/stats" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40">
|
<a
|
||||||
|
href="/stats"
|
||||||
|
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||||
|
>
|
||||||
{% if card.icon == "coffee_bean" %}{{ icons::coffee_bean("h-6 w-6 text-accent") }}{% else if card.icon == "beaker" %}{{ icons::beaker("h-6 w-6 text-accent") }}{% else if card.icon == "map" %}{{ icons::map("h-6 w-6 text-accent") }}{% else if card.icon == "location" %}{{ icons::location("h-6 w-6 text-accent") }}{% else if card.icon == "fire" %}{{ icons::fire("h-6 w-6 text-accent") }}{% endif %}
|
{% if card.icon == "coffee_bean" %}{{ icons::coffee_bean("h-6 w-6 text-accent") }}{% else if card.icon == "beaker" %}{{ icons::beaker("h-6 w-6 text-accent") }}{% else if card.icon == "map" %}{{ icons::map("h-6 w-6 text-accent") }}{% else if card.icon == "location" %}{{ icons::location("h-6 w-6 text-accent") }}{% else if card.icon == "fire" %}{{ icons::fire("h-6 w-6 text-accent") }}{% endif %}
|
||||||
<span class="text-lg font-bold text-text whitespace-nowrap">{{ card.value }}</span>
|
<span class="text-lg font-bold text-text whitespace-nowrap"
|
||||||
<span class="text-sm font-medium text-accent">{{ card.label }}</span>
|
>{{ card.value }}</span
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-accent"
|
||||||
|
>{{ card.label }}</span
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="Scroll right"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll right"
|
||||||
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-right
|
data-scroll-right
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</button>
|
</button>
|
||||||
</chip-scroll>
|
</chip-scroll>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">
|
<div
|
||||||
|
class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
{% for _ in 0..6 %}
|
{% for _ in 0..6 %}
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
|
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
|
||||||
<div class="h-5 w-16 rounded bg-surface-alt"></div>
|
<div class="h-5 w-16 rounded bg-surface-alt"></div>
|
||||||
<div class="h-4 w-20 rounded bg-surface-alt"></div>
|
<div class="h-4 w-20 rounded bg-surface-alt"></div>
|
||||||
|
|
@ -277,7 +373,9 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
<div class="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
<span class="text-lg font-semibold text-text-muted">No stats yet</span>
|
<span class="text-lg font-semibold text-text-muted"
|
||||||
|
>No stats yet</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -287,19 +385,27 @@
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center justify-between mb-5">
|
<div class="flex items-center justify-between mb-5">
|
||||||
<h2 class="text-lg font-semibold text-text">Data</h2>
|
<h2 class="text-lg font-semibold text-text">Data</h2>
|
||||||
<a href="/data" class="text-sm text-accent hover:text-accent-hover font-medium"
|
<a
|
||||||
|
href="/data"
|
||||||
|
class="text-sm text-accent hover:text-accent-hover font-medium"
|
||||||
>View all data →</a
|
>View all data →</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{% if !stats.is_empty() %}
|
{% if !stats.is_empty() %}
|
||||||
<chip-scroll class="relative block">
|
<chip-scroll class="relative block">
|
||||||
<button type="button" aria-label="Scroll left"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll left"
|
||||||
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-left
|
data-scroll-left
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_left("h-5 w-5") }}
|
{{ icons::chevron_left("h-5 w-5") }}
|
||||||
</button>
|
</button>
|
||||||
<div class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll>
|
<div
|
||||||
|
class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-x-auto scroll-smooth scrollbar-hide"
|
||||||
|
data-chip-scroll
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href="/data?type=brews"
|
href="/data?type=brews"
|
||||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||||
|
|
@ -321,7 +427,9 @@
|
||||||
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
|
||||||
>
|
>
|
||||||
{{ icons::fire("h-6 w-6 text-accent") }}
|
{{ icons::fire("h-6 w-6 text-accent") }}
|
||||||
<span class="text-lg font-bold text-text">{{ stats.roasters }}</span>
|
<span class="text-lg font-bold text-text"
|
||||||
|
>{{ stats.roasters }}</span
|
||||||
|
>
|
||||||
<span class="text-sm font-medium text-accent">Roasters</span>
|
<span class="text-sm font-medium text-accent">Roasters</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
|
@ -349,18 +457,26 @@
|
||||||
<span class="text-sm font-medium text-accent">Cafes</span>
|
<span class="text-sm font-medium text-accent">Cafes</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="Scroll right"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll right"
|
||||||
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-right
|
data-scroll-right
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</button>
|
</button>
|
||||||
</chip-scroll>
|
</chip-scroll>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">
|
<div
|
||||||
|
class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
{% for _ in 0..6 %}
|
{% for _ in 0..6 %}
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
|
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
|
||||||
<div class="h-5 w-8 rounded bg-surface-alt"></div>
|
<div class="h-5 w-8 rounded bg-surface-alt"></div>
|
||||||
<div class="h-4 w-16 rounded bg-surface-alt"></div>
|
<div class="h-4 w-16 rounded bg-surface-alt"></div>
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,32 @@
|
||||||
Sign in with a passkey to access the coffee log.
|
Sign in with a passkey to access the coffee log.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="login-error" class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div>
|
<div
|
||||||
|
id="login-error"
|
||||||
|
class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"
|
||||||
|
></div>
|
||||||
|
|
||||||
<div id="login-unsupported" class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800">
|
<div
|
||||||
This browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
|
id="login-unsupported"
|
||||||
|
class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800"
|
||||||
|
>
|
||||||
|
This browser does not support passkeys. Please use a modern browser
|
||||||
|
(Chrome, Firefox, Safari, or Edge).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<button
|
<button
|
||||||
id="login-button"
|
id="login-button"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed" >
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
{{ icons::key("h-4 w-4") }} Sign in with Passkey
|
{{ icons::key("h-4 w-4") }} Sign in with Passkey
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="login-loading" class="mt-4 hidden text-center text-sm text-text-muted">
|
<div
|
||||||
|
id="login-loading"
|
||||||
|
class="mt-4 hidden text-center text-sm text-text-muted"
|
||||||
|
>
|
||||||
<p>Waiting for passkey...</p>
|
<p>Waiting for passkey...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,21 @@
|
||||||
<div class="rounded-lg border bg-surface p-6">
|
<div class="rounded-lg border bg-surface p-6">
|
||||||
<h1 class="text-2xl font-semibold text-accent">Register</h1>
|
<h1 class="text-2xl font-semibold text-accent">Register</h1>
|
||||||
<p class="mt-2 text-sm text-text-secondary">
|
<p class="mt-2 text-sm text-text-secondary">
|
||||||
Create an account by registering a passkey. This will be used to sign in going forward.
|
Create an account by registering a passkey. This will be used to sign in
|
||||||
|
going forward.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="register-error" class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div>
|
<div
|
||||||
|
id="register-error"
|
||||||
|
class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"
|
||||||
|
></div>
|
||||||
|
|
||||||
<div id="register-unsupported" class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800">
|
<div
|
||||||
This browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
|
id="register-unsupported"
|
||||||
|
class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800"
|
||||||
|
>
|
||||||
|
This browser does not support passkeys. Please use a modern browser
|
||||||
|
(Chrome, Firefox, Safari, or Edge).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="register-form" class="mt-6 flex flex-col gap-4">
|
<div id="register-form" class="mt-6 flex flex-col gap-4">
|
||||||
|
|
@ -44,15 +52,18 @@
|
||||||
<button
|
<button
|
||||||
id="register-button"
|
id="register-button"
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed" >
|
class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
{{ icons::key("h-4 w-4") }} Register Passkey
|
{{ icons::key("h-4 w-4") }} Register Passkey
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="register-loading" class="mt-2 hidden text-center text-sm text-text-muted">
|
<div
|
||||||
|
id="register-loading"
|
||||||
|
class="mt-2 hidden text-center text-sm text-text-muted"
|
||||||
|
>
|
||||||
<p>Follow the prompts from the browser or device...</p>
|
<p>Follow the prompts from the browser or device...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,20 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · {{ roast.name }}{% endblock %}
|
{% block title %}Brewlog · {{ roast.name }}{% endblock %}
|
||||||
{% block og_title %}{{ roast.name }} — Brewlog{% endblock %}
|
{% block og_title %}{{ roast.name }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}{{ roast.name }} by {{ roast.roaster_name }} — {{ roast.origin }}{% endblock %}
|
{% block og_description %}
|
||||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
{{ roast.name }}
|
||||||
|
by {{ roast.roaster_name }} — {{ roast.origin }}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
|
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{{ roast.roaster_name }} · {{ roast.origin_flag }} {{ roast.origin }} · Added {{ roast.created_date }}
|
{{ roast.roaster_name }} · {{ roast.origin_flag }} {{ roast.origin }} ·
|
||||||
|
Added {{ roast.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -26,9 +32,11 @@
|
||||||
|
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
||||||
data-on:click="confirm('Delete this roast? This cannot be undone.') && @delete('/api/v1/roasts/{{ roast.id }}')">
|
data-on:click="confirm('Delete this roast? This cannot be undone.') && @delete('/api/v1/roasts/{{ roast.id }}')"
|
||||||
|
>
|
||||||
{{ icons::delete("h-4 w-4") }} Delete
|
{{ icons::delete("h-4 w-4") }} Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,22 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · {{ roaster.name }}{% endblock %}
|
{% block title %}Brewlog · {{ roaster.name }}{% endblock %}
|
||||||
{% block og_title %}{{ roaster.name }} — Brewlog{% endblock %}
|
{% block og_title %}{{ roaster.name }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}{{ roaster.name }} — {{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %}, {{ c }}{% endif %}{% endblock %}
|
{% block og_description %}
|
||||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
{{ roaster.name }}
|
||||||
|
— {{ roaster.country_flag }}
|
||||||
|
{{ roaster.country }}{% if let Some(c) = roaster.city %}, {{ c }}{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">{{ roaster.name }}</h1>
|
<h1 class="text-3xl font-semibold">{{ roaster.name }}</h1>
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %} · {{ c }}{% endif %} · Added {{ roaster.created_date }}
|
{{ roaster.country_flag }}
|
||||||
|
{{ roaster.country }}{% if let Some(c) = roaster.city %}· {{ c }}{% endif %}
|
||||||
|
· Added {{ roaster.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -35,7 +43,13 @@
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-text-muted">Website</dt>
|
<dt class="text-text-muted">Website</dt>
|
||||||
<dd class="font-medium">
|
<dd class="font-medium">
|
||||||
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a>
|
<a
|
||||||
|
href="{{ url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
class="text-accent hover:text-accent-hover transition"
|
||||||
|
>Visit Website</a
|
||||||
|
>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -47,9 +61,11 @@
|
||||||
|
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
|
||||||
data-on:click="confirm('Delete this roaster? This cannot be undone.') && @delete('/api/v1/roasters/{{ roaster.id }}')">
|
data-on:click="confirm('Delete this roaster? This cannot be undone.') && @delete('/api/v1/roasters/{{ roaster.id }}')"
|
||||||
|
>
|
||||||
{{ icons::delete("h-4 w-4") }} Delete
|
{{ icons::delete("h-4 w-4") }} Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,33 @@
|
||||||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/histogram.html" as histogram %} {% block title %}Brewlog · Stats{% endblock %}
|
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||||
|
{% import "partials/histogram.html" as histogram %}
|
||||||
|
{% block title %}Brewlog · Stats{% endblock %}
|
||||||
{% block og_title %}Stats — Brewlog{% endblock %}
|
{% block og_title %}Stats — Brewlog{% endblock %}
|
||||||
{% block og_description %}Aggregated coffee data across origins, consumption, and brewing.{% endblock %}
|
{% block og_description %}
|
||||||
{% block head %}<meta property="og:image" content="{{ base_url }}/static/og-image.png" />{% endblock %}
|
Aggregated coffee data across origins, consumption, and brewing.
|
||||||
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-2">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<h1 class="text-3xl font-semibold">Stats</h1>
|
<h1 class="text-3xl font-semibold">Stats</h1>
|
||||||
{% if !cache_age.is_empty() %}
|
{% if !cache_age.is_empty() %}
|
||||||
<span class="shrink-0 text-xs text-text-muted pt-2">Updated {{ cache_age }}</span>
|
<span class="shrink-0 text-xs text-text-muted pt-2"
|
||||||
|
>Updated {{ cache_age }}</span
|
||||||
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="max-w-2xl text-sm text-text-secondary">Aggregated coffee data across origins, consumption, and brewing.</p>
|
<p class="max-w-2xl text-sm text-text-secondary">
|
||||||
|
Aggregated coffee data across origins, consumption, and brewing.
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if has_data %}
|
{% if has_data %}
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
{% include "partials/tab_bar.html" %}
|
{% include "partials/tab_bar.html" %}
|
||||||
|
|
||||||
<div id="stats-content">
|
<div id="stats-content">{{ content|safe }}</div>
|
||||||
{{ content|safe }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -27,19 +35,31 @@
|
||||||
<h2 class="text-lg font-semibold text-text">Roast Stats</h2>
|
<h2 class="text-lg font-semibold text-text">Roast Stats</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
{{ icons::map("h-6 w-6 text-accent") }}
|
{{ icons::map("h-6 w-6 text-accent") }}
|
||||||
<span class="text-lg font-bold text-text">{{ roast_summary.unique_origins }}</span>
|
<span class="text-lg font-bold text-text"
|
||||||
|
>{{ roast_summary.unique_origins }}</span
|
||||||
|
>
|
||||||
<span class="text-sm font-medium text-accent">Origins</span>
|
<span class="text-sm font-medium text-accent">Origins</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
{{ icons::location("h-6 w-6 text-accent") }}
|
{{ icons::location("h-6 w-6 text-accent") }}
|
||||||
<span class="text-lg font-bold text-text truncate max-w-full">{% match roast_summary.top_origin %}{% when Some with (v) %}{{ v }}{% when None %}—{% endmatch %}</span>
|
<span class="text-lg font-bold text-text truncate max-w-full"
|
||||||
|
>{% match roast_summary.top_origin %}{% when Some with (v) %}{{ v }}{% when None %}—{% endmatch %}</span
|
||||||
|
>
|
||||||
<span class="text-sm font-medium text-accent">Top Origin</span>
|
<span class="text-sm font-medium text-accent">Top Origin</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
{{ icons::fire("h-6 w-6 text-accent") }}
|
{{ icons::fire("h-6 w-6 text-accent") }}
|
||||||
<span class="text-lg font-bold text-text truncate max-w-full">{% match roast_summary.top_roaster %}{% when Some with (v) %}{{ v }}{% when None %}—{% endmatch %}</span>
|
<span class="text-lg font-bold text-text truncate max-w-full"
|
||||||
|
>{% match roast_summary.top_roaster %}{% when Some with (v) %}{{ v }}{% when None %}—{% endmatch %}</span
|
||||||
|
>
|
||||||
<span class="text-sm font-medium text-accent">Top Roaster</span>
|
<span class="text-sm font-medium text-accent">Top Roaster</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,13 +67,17 @@
|
||||||
<div class="mt-5 grid gap-5 md:grid-cols-2">
|
<div class="mt-5 grid gap-5 md:grid-cols-2">
|
||||||
{% if !roast_summary.origin_counts.is_empty() %}
|
{% if !roast_summary.origin_counts.is_empty() %}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-text mb-3">Top 5 Origins</h3>
|
<h3 class="text-sm font-semibold text-text mb-3">
|
||||||
|
Top 5 Origins
|
||||||
|
</h3>
|
||||||
{{ histogram::bar_chart(roast_summary.origin_counts, roast_summary.max_origin_count) }}
|
{{ histogram::bar_chart(roast_summary.origin_counts, roast_summary.max_origin_count) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if !roast_summary.flavour_counts.is_empty() %}
|
{% if !roast_summary.flavour_counts.is_empty() %}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-text mb-3">Top 5 Flavours</h3>
|
<h3 class="text-sm font-semibold text-text mb-3">
|
||||||
|
Top 5 Flavours
|
||||||
|
</h3>
|
||||||
{{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }}
|
{{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -66,24 +90,40 @@
|
||||||
<h2 class="text-lg font-semibold text-text">Consumption</h2>
|
<h2 class="text-lg font-semibold text-text">Consumption</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
|
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
|
||||||
<span class="text-lg font-bold text-text">{{ consumption_30d_weight }}</span>
|
<span class="text-lg font-bold text-text"
|
||||||
|
>{{ consumption_30d_weight }}</span
|
||||||
|
>
|
||||||
<span class="text-sm font-medium text-accent">Last 30 Days</span>
|
<span class="text-sm font-medium text-accent">Last 30 Days</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
|
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
|
||||||
<span class="text-lg font-bold text-text">{{ consumption_all_time_weight }}</span>
|
<span class="text-lg font-bold text-text"
|
||||||
|
>{{ consumption_all_time_weight }}</span
|
||||||
|
>
|
||||||
<span class="text-sm font-medium text-accent">All Time</span>
|
<span class="text-sm font-medium text-accent">All Time</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
{{ icons::beaker("h-6 w-6 text-accent") }}
|
{{ icons::beaker("h-6 w-6 text-accent") }}
|
||||||
<span class="text-lg font-bold text-text">{{ consumption.brews_last_30_days }}</span>
|
<span class="text-lg font-bold text-text"
|
||||||
|
>{{ consumption.brews_last_30_days }}</span
|
||||||
|
>
|
||||||
<span class="text-sm font-medium text-accent">Brews (30d)</span>
|
<span class="text-sm font-medium text-accent">Brews (30d)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
{{ icons::beaker("h-6 w-6 text-accent") }}
|
{{ icons::beaker("h-6 w-6 text-accent") }}
|
||||||
<span class="text-lg font-bold text-text">{{ consumption.brews_all_time }}</span>
|
<span class="text-lg font-bold text-text"
|
||||||
|
>{{ consumption.brews_all_time }}</span
|
||||||
|
>
|
||||||
<span class="text-sm font-medium text-accent">Brews (All Time)</span>
|
<span class="text-sm font-medium text-accent">Brews (All Time)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -98,13 +138,21 @@
|
||||||
{% if !brewing_summary.brewer_counts.is_empty() %}
|
{% if !brewing_summary.brewer_counts.is_empty() %}
|
||||||
<div class="rounded-lg border bg-surface p-5">
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
<h3 class="text-sm font-semibold text-text mb-4">Brewer Usage</h3>
|
<h3 class="text-sm font-semibold text-text mb-4">Brewer Usage</h3>
|
||||||
<donut-chart data-icon="beaker" data-items="{% for item in brewing_summary.brewer_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"></donut-chart>
|
<donut-chart
|
||||||
|
data-icon="beaker"
|
||||||
|
data-items="{% for item in brewing_summary.brewer_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"
|
||||||
|
></donut-chart>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if !brewing_summary.grinder_counts.is_empty() %}
|
{% if !brewing_summary.grinder_counts.is_empty() %}
|
||||||
<div class="rounded-lg border bg-surface p-5">
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
<h3 class="text-sm font-semibold text-text mb-4">Grinder Usage</h3>
|
<h3 class="text-sm font-semibold text-text mb-4">
|
||||||
<donut-chart data-icon="grinder" data-items="{% for item in brewing_summary.grinder_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"></donut-chart>
|
Grinder Usage
|
||||||
|
</h3>
|
||||||
|
<donut-chart
|
||||||
|
data-icon="grinder"
|
||||||
|
data-items="{% for item in brewing_summary.grinder_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"
|
||||||
|
></donut-chart>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -112,13 +160,17 @@
|
||||||
<div class="mt-5 grid gap-5 md:grid-cols-2">
|
<div class="mt-5 grid gap-5 md:grid-cols-2">
|
||||||
{% if !grinder_weights.is_empty() %}
|
{% if !grinder_weights.is_empty() %}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-text mb-3">Coffee per Grinder</h3>
|
<h3 class="text-sm font-semibold text-text mb-3">
|
||||||
|
Coffee per Grinder
|
||||||
|
</h3>
|
||||||
{{ histogram::bar_chart_weight(grinder_weights, max_grinder_weight) }}
|
{{ histogram::bar_chart_weight(grinder_weights, max_grinder_weight) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if !brewing_summary.brew_time_distribution.is_empty() %}
|
{% if !brewing_summary.brew_time_distribution.is_empty() %}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-text mb-3">Brew Time Distribution</h3>
|
<h3 class="text-sm font-semibold text-text mb-3">
|
||||||
|
Brew Time Distribution
|
||||||
|
</h3>
|
||||||
{{ histogram::bar_chart(brewing_summary.brew_time_distribution, brewing_summary.max_brew_time_count) }}
|
{{ histogram::bar_chart(brewing_summary.brew_time_distribution, brewing_summary.max_brew_time_count) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -126,13 +178,20 @@
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="flex flex-col gap-8 blur-[2px] select-none pointer-events-none" aria-hidden="true">
|
<div
|
||||||
|
class="flex flex-col gap-8 blur-[2px] select-none pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<!-- Placeholder: Roast Stats -->
|
<!-- Placeholder: Roast Stats -->
|
||||||
<section>
|
<section>
|
||||||
<div class="mb-5"><div class="h-5 w-28 rounded bg-surface-alt"></div></div>
|
<div class="mb-5">
|
||||||
|
<div class="h-5 w-28 rounded bg-surface-alt"></div>
|
||||||
|
</div>
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
{% for _ in 0..3 %}
|
{% for _ in 0..3 %}
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
|
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
|
||||||
<div class="h-5 w-10 rounded bg-surface-alt"></div>
|
<div class="h-5 w-10 rounded bg-surface-alt"></div>
|
||||||
<div class="h-4 w-16 rounded bg-surface-alt"></div>
|
<div class="h-4 w-16 rounded bg-surface-alt"></div>
|
||||||
|
|
@ -142,10 +201,14 @@
|
||||||
</section>
|
</section>
|
||||||
<!-- Placeholder: Consumption -->
|
<!-- Placeholder: Consumption -->
|
||||||
<section>
|
<section>
|
||||||
<div class="mb-5"><div class="h-5 w-32 rounded bg-surface-alt"></div></div>
|
<div class="mb-5">
|
||||||
|
<div class="h-5 w-32 rounded bg-surface-alt"></div>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{% for _ in 0..4 %}
|
{% for _ in 0..4 %}
|
||||||
<div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
|
||||||
|
>
|
||||||
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
|
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
|
||||||
<div class="h-5 w-12 rounded bg-surface-alt"></div>
|
<div class="h-5 w-12 rounded bg-surface-alt"></div>
|
||||||
<div class="h-4 w-20 rounded bg-surface-alt"></div>
|
<div class="h-4 w-20 rounded bg-surface-alt"></div>
|
||||||
|
|
@ -155,7 +218,9 @@
|
||||||
</section>
|
</section>
|
||||||
<!-- Placeholder: Brewing charts -->
|
<!-- Placeholder: Brewing charts -->
|
||||||
<section>
|
<section>
|
||||||
<div class="mb-5"><div class="h-5 w-24 rounded bg-surface-alt"></div></div>
|
<div class="mb-5">
|
||||||
|
<div class="h-5 w-24 rounded bg-surface-alt"></div>
|
||||||
|
</div>
|
||||||
<div class="grid gap-5 md:grid-cols-2">
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
{% for _ in 0..2 %}
|
{% for _ in 0..2 %}
|
||||||
<div class="rounded-lg border bg-surface p-5">
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %} {% block content %}
|
{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %}
|
||||||
|
{% block content %}
|
||||||
<div>
|
<div>
|
||||||
<section
|
<section
|
||||||
id="timeline-events"
|
id="timeline-events"
|
||||||
|
|
@ -17,7 +18,10 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="timeline-list relative" id="timeline-items">
|
<div class="timeline-list relative" id="timeline-items">
|
||||||
{# Single central timeline line for all months #}
|
{# Single central timeline line for all months #}
|
||||||
<div class="timeline-line absolute top-0 bottom-0 w-1 bg-border" aria-hidden="true"></div>
|
<div
|
||||||
|
class="timeline-line absolute top-0 bottom-0 w-1 bg-border"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
{% for month in months %}{% include "partials/timeline_month.html" %}{% endfor %}
|
{% for month in months %}{% include "partials/timeline_month.html" %}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -38,8 +42,16 @@
|
||||||
<span aria-hidden="true">↓</span>
|
<span aria-hidden="true">↓</span>
|
||||||
Load more
|
Load more
|
||||||
</button>
|
</button>
|
||||||
<p id="timeline-status" class="text-xs text-text-muted" hidden>Loading…</p>
|
<p id="timeline-status" class="text-xs text-text-muted" hidden>
|
||||||
<p id="timeline-end" class="text-sm text-text-muted" style="{% if events.has_next() || months.is_empty() %}display: none{% endif %}">No more events.</p>
|
Loading…
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
id="timeline-end"
|
||||||
|
class="text-sm text-text-muted"
|
||||||
|
style="{% if events.has_next() || months.is_empty() %}display: none{% endif %}"
|
||||||
|
>
|
||||||
|
No more events.
|
||||||
|
</p>
|
||||||
<p id="timeline-error" class="text-sm text-red-600" hidden></p>
|
<p id="timeline-error" class="text-sm text-red-600" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="timeline-sentinel" class="h-1"></div>
|
<div id="timeline-sentinel" class="h-1"></div>
|
||||||
|
|
@ -47,88 +59,97 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
const loader = document.getElementById("timeline-loader")
|
const loader = document.getElementById("timeline-loader");
|
||||||
const itemsContainer = document.getElementById("timeline-items")
|
const itemsContainer = document.getElementById("timeline-items");
|
||||||
const loadMoreButton = document.getElementById("timeline-load-more")
|
const loadMoreButton = document.getElementById("timeline-load-more");
|
||||||
const statusLine = document.getElementById("timeline-status")
|
const statusLine = document.getElementById("timeline-status");
|
||||||
const endLine = document.getElementById("timeline-end")
|
const endLine = document.getElementById("timeline-end");
|
||||||
const errorLine = document.getElementById("timeline-error")
|
const errorLine = document.getElementById("timeline-error");
|
||||||
const sentinel = document.getElementById("timeline-sentinel")
|
const sentinel = document.getElementById("timeline-sentinel");
|
||||||
const emptyState = document.querySelector('[data-role="timeline-empty-state"]')
|
const emptyState = document.querySelector(
|
||||||
const section = document.getElementById("timeline-events")
|
'[data-role="timeline-empty-state"]',
|
||||||
|
);
|
||||||
|
const section = document.getElementById("timeline-events");
|
||||||
|
|
||||||
if (loader && sentinel) {
|
if (loader && sentinel) {
|
||||||
let nextUrl = loader.dataset.nextUrl || ""
|
let nextUrl = loader.dataset.nextUrl || "";
|
||||||
let hasMorePages = loader.dataset.hasMore === "true"
|
let hasMorePages = loader.dataset.hasMore === "true";
|
||||||
let loading = false
|
let loading = false;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries.some((entry) => entry.isIntersecting) && hasMorePages && nextUrl) {
|
if (
|
||||||
void loadMore()
|
entries.some((entry) => entry.isIntersecting) &&
|
||||||
|
hasMorePages &&
|
||||||
|
nextUrl
|
||||||
|
) {
|
||||||
|
void loadMore();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: "200px" }
|
{ rootMargin: "200px" },
|
||||||
)
|
);
|
||||||
|
|
||||||
const setHasMore = (hasMore, url) => {
|
const setHasMore = (hasMore, url) => {
|
||||||
hasMorePages = hasMore
|
hasMorePages = hasMore;
|
||||||
nextUrl = url || ""
|
nextUrl = url || "";
|
||||||
loader.dataset.hasMore = hasMore ? "true" : "false"
|
loader.dataset.hasMore = hasMore ? "true" : "false";
|
||||||
loader.dataset.nextUrl = nextUrl
|
loader.dataset.nextUrl = nextUrl;
|
||||||
|
|
||||||
if (!hasMore) {
|
if (!hasMore) {
|
||||||
if (loadMoreButton) loadMoreButton.style.display = "none"
|
if (loadMoreButton) loadMoreButton.style.display = "none";
|
||||||
if (endLine) endLine.style.display = ""
|
if (endLine) endLine.style.display = "";
|
||||||
observer.disconnect()
|
observer.disconnect();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ensureItemsContainer = () => {
|
const ensureItemsContainer = () => {
|
||||||
if (itemsContainer) return itemsContainer
|
if (itemsContainer) return itemsContainer;
|
||||||
// First load on a previously empty timeline: create the container
|
// First load on a previously empty timeline: create the container
|
||||||
const container = document.createElement("div")
|
const container = document.createElement("div");
|
||||||
container.className = "timeline-list relative"
|
container.className = "timeline-list relative";
|
||||||
container.id = "timeline-items"
|
container.id = "timeline-items";
|
||||||
const line = document.createElement("div")
|
const line = document.createElement("div");
|
||||||
line.className = "timeline-line absolute top-0 bottom-0 w-1 bg-border"
|
line.className = "timeline-line absolute top-0 bottom-0 w-1 bg-border";
|
||||||
line.setAttribute("aria-hidden", "true")
|
line.setAttribute("aria-hidden", "true");
|
||||||
container.appendChild(line)
|
container.appendChild(line);
|
||||||
section.insertBefore(container, loader)
|
section.insertBefore(container, loader);
|
||||||
return container
|
return container;
|
||||||
}
|
};
|
||||||
|
|
||||||
const appendMonths = (chunk) => {
|
const appendMonths = (chunk) => {
|
||||||
const monthsFragment = chunk.querySelector("[data-chunk-months]")
|
const monthsFragment = chunk.querySelector("[data-chunk-months]");
|
||||||
if (!monthsFragment) return
|
if (!monthsFragment) return;
|
||||||
|
|
||||||
const container = ensureItemsContainer()
|
const container = ensureItemsContainer();
|
||||||
|
|
||||||
for (const node of Array.from(monthsFragment.children)) {
|
for (const node of Array.from(monthsFragment.children)) {
|
||||||
// Skip duplicate month headings (same month spanning a page boundary)
|
// Skip duplicate month headings (same month spanning a page boundary)
|
||||||
if (node.hasAttribute("data-timeline-month") && document.getElementById(node.id)) {
|
if (
|
||||||
continue
|
node.hasAttribute("data-timeline-month") &&
|
||||||
}
|
document.getElementById(node.id)
|
||||||
container.appendChild(node)
|
) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
container.appendChild(node);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const clearEmptyState = () => {
|
const clearEmptyState = () => {
|
||||||
if (emptyState && !emptyState.hidden) {
|
if (emptyState && !emptyState.hidden) {
|
||||||
emptyState.hidden = true
|
emptyState.hidden = true;
|
||||||
loader.dataset.empty = "false"
|
loader.dataset.empty = "false";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (loading || !hasMorePages || !nextUrl) return
|
if (loading || !hasMorePages || !nextUrl) return;
|
||||||
|
|
||||||
loading = true
|
loading = true;
|
||||||
if (statusLine) {
|
if (statusLine) {
|
||||||
statusLine.hidden = false
|
statusLine.hidden = false;
|
||||||
}
|
}
|
||||||
if (errorLine) {
|
if (errorLine) {
|
||||||
errorLine.hidden = true
|
errorLine.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -137,46 +158,47 @@
|
||||||
"X-Requested-With": "fetch",
|
"X-Requested-With": "fetch",
|
||||||
"datastar-request": "true",
|
"datastar-request": "true",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Unexpected response: ${response.status}`)
|
throw new Error(`Unexpected response: ${response.status}`);
|
||||||
}
|
}
|
||||||
const html = await response.text()
|
const html = await response.text();
|
||||||
const template = document.createElement("template")
|
const template = document.createElement("template");
|
||||||
template.innerHTML = html.trim()
|
template.innerHTML = html.trim();
|
||||||
const chunk = template.content.querySelector("[data-timeline-chunk]")
|
const chunk = template.content.querySelector("[data-timeline-chunk]");
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
throw new Error("Invalid timeline chunk payload")
|
throw new Error("Invalid timeline chunk payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
appendMonths(chunk)
|
appendMonths(chunk);
|
||||||
clearEmptyState()
|
clearEmptyState();
|
||||||
|
|
||||||
const hasMore = chunk.dataset.hasMore === "true"
|
const hasMore = chunk.dataset.hasMore === "true";
|
||||||
const url = chunk.dataset.nextUrl || ""
|
const url = chunk.dataset.nextUrl || "";
|
||||||
setHasMore(hasMore, url)
|
setHasMore(hasMore, url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error);
|
||||||
if (errorLine) {
|
if (errorLine) {
|
||||||
errorLine.textContent = "Failed to load more events. Please try again."
|
errorLine.textContent =
|
||||||
errorLine.hidden = false
|
"Failed to load more events. Please try again.";
|
||||||
|
errorLine.hidden = false;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false;
|
||||||
if (statusLine) {
|
if (statusLine) {
|
||||||
statusLine.hidden = true
|
statusLine.hidden = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loadMoreButton) {
|
if (loadMoreButton) {
|
||||||
loadMoreButton.addEventListener("click", () => {
|
loadMoreButton.addEventListener("click", () => {
|
||||||
void loadMore()
|
void loadMore();
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMorePages && loader.dataset.empty !== "true") {
|
if (hasMorePages && loader.dataset.empty !== "true") {
|
||||||
observer.observe(sentinel)
|
observer.observe(sentinel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,36 +206,36 @@
|
||||||
const stickyObserver = new IntersectionObserver(
|
const stickyObserver = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
entry.target.classList.toggle("is-stuck", !entry.isIntersecting)
|
entry.target.classList.toggle("is-stuck", !entry.isIntersecting);
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
{ rootMargin: "-1px 0px 0px 0px", threshold: 1 }
|
{ rootMargin: "-1px 0px 0px 0px", threshold: 1 },
|
||||||
)
|
);
|
||||||
|
|
||||||
// Use MutationObserver to watch for new month headings being added
|
// Use MutationObserver to watch for new month headings being added
|
||||||
const timelineItems = document.getElementById("timeline-items")
|
const timelineItems = document.getElementById("timeline-items");
|
||||||
if (timelineItems) {
|
if (timelineItems) {
|
||||||
const mutationObserver = new MutationObserver((mutations) => {
|
const mutationObserver = new MutationObserver((mutations) => {
|
||||||
mutations.forEach((mutation) => {
|
mutations.forEach((mutation) => {
|
||||||
mutation.addedNodes.forEach((node) => {
|
mutation.addedNodes.forEach((node) => {
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
if (node.matches?.(".timeline-heading")) {
|
if (node.matches?.(".timeline-heading")) {
|
||||||
stickyObserver.observe(node)
|
stickyObserver.observe(node);
|
||||||
}
|
}
|
||||||
const heading = node.querySelector?.(".timeline-heading")
|
const heading = node.querySelector?.(".timeline-heading");
|
||||||
if (heading) {
|
if (heading) {
|
||||||
stickyObserver.observe(heading)
|
stickyObserver.observe(heading);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
mutationObserver.observe(timelineItems, { childList: true })
|
mutationObserver.observe(timelineItems, { childList: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe existing headings
|
// Observe existing headings
|
||||||
document.querySelectorAll(".timeline-heading").forEach((heading) => {
|
document.querySelectorAll(".timeline-heading").forEach((heading) => {
|
||||||
stickyObserver.observe(heading)
|
stickyObserver.observe(heading);
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,39 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% macro card(bag, is_authenticated) %}
|
{% macro card(bag, is_authenticated) %}
|
||||||
<a href="/bags/{{ bag.id }}" id="bag-card-{{ bag.id }}" class="relative w-[45vw] md:w-[200px] h-[200px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col transition hover:border-accent/40">
|
<a
|
||||||
|
href="/bags/{{ bag.id }}"
|
||||||
|
id="bag-card-{{ bag.id }}"
|
||||||
|
class="relative w-[45vw] md:w-[200px] h-[200px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col transition hover:border-accent/40"
|
||||||
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="block font-semibold text-text truncate">{{ bag.roast_name }}</span>
|
<span class="block font-semibold text-text truncate"
|
||||||
<p class="mt-1 text-sm text-text-muted truncate">{{ bag.roaster_name }}</p>
|
>{{ bag.roast_name }}</span
|
||||||
|
>
|
||||||
|
<p class="mt-1 text-sm text-text-muted truncate">
|
||||||
|
{{ bag.roaster_name }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="h-2 rounded-full bg-surface-alt overflow-hidden">
|
<div class="h-2 rounded-full bg-surface-alt overflow-hidden">
|
||||||
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div>
|
<div
|
||||||
|
class="h-full rounded-full bg-accent"
|
||||||
|
style="width: {{ bag.used_percent }}%"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">{{ bag.remaining }} / {{ bag.amount }}</p>
|
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">
|
||||||
|
{{ bag.remaining }} / {{ bag.amount }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<div class="relative z-10 mt-3">
|
<div class="relative z-10 mt-3">
|
||||||
<span onclick="event.preventDefault(); window.location.href='/add?type=brew&bag_id={{ bag.id }}';"
|
<span
|
||||||
|
onclick="event.preventDefault(); window.location.href='/add?type=brew&bag_id={{ bag.id }}';"
|
||||||
title="Brew"
|
title="Brew"
|
||||||
class="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt cursor-pointer">
|
class="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt cursor-pointer"
|
||||||
{{ icons::beaker("h-4 w-4") }}
|
>
|
||||||
Brew
|
{{ icons::beaker("h-4 w-4") }} Brew
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,41 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% macro card(brew, is_authenticated) %}
|
{% macro card(brew, is_authenticated) %}
|
||||||
<a href="/brews/{{ brew.id }}" class="relative w-[45vw] md:w-[200px] h-[279px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col transition hover:border-accent/40">
|
<a
|
||||||
|
href="/brews/{{ brew.id }}"
|
||||||
|
class="relative w-[45vw] md:w-[200px] h-[279px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col transition hover:border-accent/40"
|
||||||
|
>
|
||||||
<div class="h-[3.25rem] overflow-hidden">
|
<div class="h-[3.25rem] overflow-hidden">
|
||||||
<span class="block font-semibold text-text truncate">{{ brew.roast_name }}</span>
|
<span class="block font-semibold text-text truncate"
|
||||||
<p class="mt-0.5 text-xs text-text-muted">{{ brew.relative_date_label }}</p>
|
>{{ brew.roast_name }}</span
|
||||||
|
>
|
||||||
|
<p class="mt-0.5 text-xs text-text-muted">
|
||||||
|
{{ brew.relative_date_label }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex-1 space-y-1 text-text-secondary" style="font-size: 0.8125rem">
|
<div
|
||||||
<p>{{ brew.coffee_weight }} · {{ brew.water_volume }}{% if let Some(bt) = brew.brew_time %} · {{ bt }}{% endif %}</p>
|
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.grinder_model }} · {{ brew.grind_setting }}</p>
|
||||||
<p>{{ brew.brewer_name }}</p>
|
<p>{{ brew.brewer_name }}</p>
|
||||||
{% if let Some(fp) = brew.filter_paper_name %}<p>{{ fp }}</p>{% endif %}
|
{% if let Some(fp) = brew.filter_paper_name %}<p>{{ fp }}</p>{% endif %}
|
||||||
{% if !brew.quick_notes.is_empty() %}<p class="truncate">{{ brew.quick_notes_label }}</p>{% endif %}
|
{% if !brew.quick_notes.is_empty() %}
|
||||||
|
<p class="truncate">{{ brew.quick_notes_label }}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<div class="relative z-10 mt-3">
|
<div class="relative z-10 mt-3">
|
||||||
<span onclick="event.preventDefault(); window.location.href='{{ brew.brew_again_url() }}';"
|
<span
|
||||||
class="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt cursor-pointer">
|
onclick="event.preventDefault(); window.location.href='{{ brew.brew_again_url() }}';"
|
||||||
{{ icons::beaker("h-4 w-4") }}
|
class="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt cursor-pointer"
|
||||||
Brew Again
|
>
|
||||||
|
{{ icons::beaker("h-4 w-4") }} Brew Again
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
|
||||||
{% macro share_button() %}
|
{% macro share_button() %}
|
||||||
<button onclick="shareLink(this)" class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium text-text-muted transition hover:bg-surface-alt hover:text-text shrink-0">
|
<button
|
||||||
|
onclick="shareLink(this)"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium text-text-muted transition hover:bg-surface-alt hover:text-text shrink-0"
|
||||||
|
>
|
||||||
<span class="share-icon">{{ icons::clipboard("h-4 w-4 shrink-0") }}</span>
|
<span class="share-icon">{{ icons::clipboard("h-4 w-4 shrink-0") }}</span>
|
||||||
<span class="share-icon-check hidden">{{ icons::check("h-4 w-4 shrink-0") }}</span>
|
<span class="share-icon-check hidden"
|
||||||
|
>{{ icons::check("h-4 w-4 shrink-0") }}</span
|
||||||
|
>
|
||||||
<span class="share-label">Share</span>
|
<span class="share-label">Share</span>
|
||||||
</button>
|
</button>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
@ -12,13 +17,13 @@
|
||||||
<script>
|
<script>
|
||||||
const shareLink = (btn) => {
|
const shareLink = (btn) => {
|
||||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||||
btn.querySelector('.share-icon').classList.add('hidden');
|
btn.querySelector(".share-icon").classList.add("hidden");
|
||||||
btn.querySelector('.share-icon-check').classList.remove('hidden');
|
btn.querySelector(".share-icon-check").classList.remove("hidden");
|
||||||
btn.querySelector('.share-label').textContent = 'Copied';
|
btn.querySelector(".share-label").textContent = "Copied";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.querySelector('.share-icon').classList.remove('hidden');
|
btn.querySelector(".share-icon").classList.remove("hidden");
|
||||||
btn.querySelector('.share-icon-check').classList.add('hidden');
|
btn.querySelector(".share-icon-check").classList.add("hidden");
|
||||||
btn.querySelector('.share-label').textContent = 'Share';
|
btn.querySelector(".share-label").textContent = "Share";
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -76,15 +81,22 @@ const shareLink = (btn) => {
|
||||||
|
|
||||||
{% macro map_with_legend_1(map_countries, map_max, label1, opacity1) %}
|
{% macro map_with_legend_1(map_countries, map_max, label1, opacity1) %}
|
||||||
{% if !map_countries.is_empty() %}
|
{% if !map_countries.is_empty() %}
|
||||||
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
|
<div
|
||||||
|
class="relative rounded-lg border bg-surface overflow-hidden flex items-center"
|
||||||
|
>
|
||||||
<world-map
|
<world-map
|
||||||
class="block w-full"
|
class="block w-full"
|
||||||
data-countries="{{ map_countries }}"
|
data-countries="{{ map_countries }}"
|
||||||
data-max="{{ map_max }}"
|
data-max="{{ map_max }}"
|
||||||
></world-map>
|
></world-map>
|
||||||
<div class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted">
|
<div
|
||||||
|
class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted"
|
||||||
|
>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"></span> {{ label1 }}
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"
|
||||||
|
></span>
|
||||||
|
{{ label1 }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,18 +105,28 @@ const shareLink = (btn) => {
|
||||||
|
|
||||||
{% macro map_with_legend_2(map_countries, map_max, label1, opacity1, label2, opacity2) %}
|
{% macro map_with_legend_2(map_countries, map_max, label1, opacity1, label2, opacity2) %}
|
||||||
{% if !map_countries.is_empty() %}
|
{% if !map_countries.is_empty() %}
|
||||||
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
|
<div
|
||||||
|
class="relative rounded-lg border bg-surface overflow-hidden flex items-center"
|
||||||
|
>
|
||||||
<world-map
|
<world-map
|
||||||
class="block w-full"
|
class="block w-full"
|
||||||
data-countries="{{ map_countries }}"
|
data-countries="{{ map_countries }}"
|
||||||
data-max="{{ map_max }}"
|
data-max="{{ map_max }}"
|
||||||
></world-map>
|
></world-map>
|
||||||
<div class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted">
|
<div
|
||||||
|
class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted"
|
||||||
|
>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"></span> {{ label1 }}
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"
|
||||||
|
></span>
|
||||||
|
{{ label1 }}
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"></span> {{ label2 }}
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"
|
||||||
|
></span>
|
||||||
|
{{ label2 }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,21 +135,34 @@ const shareLink = (btn) => {
|
||||||
|
|
||||||
{% macro map_with_legend_3(map_countries, map_max, label1, opacity1, label2, opacity2, label3, opacity3) %}
|
{% macro map_with_legend_3(map_countries, map_max, label1, opacity1, label2, opacity2, label3, opacity3) %}
|
||||||
{% if !map_countries.is_empty() %}
|
{% if !map_countries.is_empty() %}
|
||||||
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
|
<div
|
||||||
|
class="relative rounded-lg border bg-surface overflow-hidden flex items-center"
|
||||||
|
>
|
||||||
<world-map
|
<world-map
|
||||||
class="block w-full"
|
class="block w-full"
|
||||||
data-countries="{{ map_countries }}"
|
data-countries="{{ map_countries }}"
|
||||||
data-max="{{ map_max }}"
|
data-max="{{ map_max }}"
|
||||||
></world-map>
|
></world-map>
|
||||||
<div class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted">
|
<div
|
||||||
|
class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted"
|
||||||
|
>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"></span> {{ label1 }}
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"
|
||||||
|
></span>
|
||||||
|
{{ label1 }}
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"></span> {{ label2 }}
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"
|
||||||
|
></span>
|
||||||
|
{{ label2 }}
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity3 }}"></span> {{ label3 }}
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity3 }}"
|
||||||
|
></span>
|
||||||
|
{{ label3 }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -158,7 +193,13 @@ const shareLink = (btn) => {
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-text-muted">Website</dt>
|
<dt class="text-text-muted">Website</dt>
|
||||||
<dd class="font-medium">
|
<dd class="font-medium">
|
||||||
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a>
|
<a
|
||||||
|
href="{{ url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
class="text-accent hover:text-accent-hover transition"
|
||||||
|
>Visit Website</a
|
||||||
|
>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
<!-- Hidden inputs for submission (always present, bound to signals) -->
|
<!-- Hidden inputs for submission (always present, bound to signals) -->
|
||||||
<input type="hidden" name="matched_roast_id" data-attr:value="$_matchedRoastId" />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="matched_roast_id"
|
||||||
|
data-attr:value="$_matchedRoastId"
|
||||||
|
/>
|
||||||
<input type="hidden" name="roaster_name" data-attr:value="$_roasterName" />
|
<input type="hidden" name="roaster_name" data-attr:value="$_roasterName" />
|
||||||
<input type="hidden" name="roaster_country" data-attr:value="$_roasterCountry" />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="roaster_country"
|
||||||
|
data-attr:value="$_roasterCountry"
|
||||||
|
/>
|
||||||
<input type="hidden" name="roaster_city" data-attr:value="$_roasterCity" />
|
<input type="hidden" name="roaster_city" data-attr:value="$_roasterCity" />
|
||||||
<input type="hidden" name="roaster_homepage" data-attr:value="$_roasterHomepage" />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="roaster_homepage"
|
||||||
|
data-attr:value="$_roasterHomepage"
|
||||||
|
/>
|
||||||
<input type="hidden" name="roast_name" data-attr:value="$_roastName" />
|
<input type="hidden" name="roast_name" data-attr:value="$_roastName" />
|
||||||
<input type="hidden" name="origin" data-attr:value="$_origin" />
|
<input type="hidden" name="origin" data-attr:value="$_origin" />
|
||||||
<input type="hidden" name="region" data-attr:value="$_region" />
|
<input type="hidden" name="region" data-attr:value="$_region" />
|
||||||
|
|
@ -14,36 +26,71 @@
|
||||||
<!-- Roaster: card (matched) -->
|
<!-- Roaster: card (matched) -->
|
||||||
<div data-show="$_matchedRoasterId">
|
<div data-show="$_matchedRoasterId">
|
||||||
<h3 class="text-base font-semibold text-text">Roaster</h3>
|
<h3 class="text-base font-semibold text-text">Roaster</h3>
|
||||||
<div class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer" data-on:click="$_matchedRoasterId = ''; $_matchedRoastId = ''">
|
<div
|
||||||
|
class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer"
|
||||||
|
data-on:click="$_matchedRoasterId = ''; $_matchedRoastId = ''"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{{ icons::fire("h-4 w-4 text-accent shrink-0") }}
|
{{ icons::fire("h-4 w-4 text-accent shrink-0") }}
|
||||||
<span class="font-medium text-text" data-text="$_roasterName"></span>
|
<span class="font-medium text-text" data-text="$_roasterName"></span>
|
||||||
<span class="text-xs text-text-muted" data-show="$_roasterCountry" data-text="$_roasterCountry" style="display:none"></span>
|
<span
|
||||||
|
class="text-xs text-text-muted"
|
||||||
|
data-show="$_roasterCountry"
|
||||||
|
data-text="$_roasterCountry"
|
||||||
|
style="display:none"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
|
<button
|
||||||
data-on:click="$_matchedRoasterId = ''; $_matchedRoastId = ''">{{ icons::pencil("h-3 w-3") }} Change</button>
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
|
||||||
|
data-on:click="$_matchedRoasterId = ''; $_matchedRoastId = ''"
|
||||||
|
>
|
||||||
|
{{ icons::pencil("h-3 w-3") }} Change
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Roaster: form (not matched) -->
|
<!-- Roaster: form (not matched) -->
|
||||||
<div data-show="!$_matchedRoasterId" style="display:none">
|
<div data-show="!$_matchedRoasterId" style="display:none">
|
||||||
<h3 class="text-base font-semibold text-text">Roaster</h3>
|
<h3 class="text-base font-semibold text-text">Roaster</h3>
|
||||||
<p class="mt-1 text-sm text-text-secondary">If this roaster already exists, it will be matched automatically.</p>
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
If this roaster already exists, it will be matched automatically.
|
||||||
|
</p>
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Name *</span>
|
<span class="text-text">Name *</span>
|
||||||
<input type="text" class="input-field" placeholder="Example Coffee Roasters" data-bind:_roaster-name />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Example Coffee Roasters"
|
||||||
|
data-bind:_roaster-name
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Country *</span>
|
<span class="text-text">Country *</span>
|
||||||
<input type="text" class="input-field" placeholder="United States" data-bind:_roaster-country />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="United States"
|
||||||
|
data-bind:_roaster-country
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">City</span>
|
<span class="text-text">City</span>
|
||||||
<input type="text" class="input-field" placeholder="Portland" data-bind:_roaster-city />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Portland"
|
||||||
|
data-bind:_roaster-city
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Homepage</span>
|
<span class="text-text">Homepage</span>
|
||||||
<input type="url" class="input-field" placeholder="https://example.coffee" data-bind:_roaster-homepage />
|
<input
|
||||||
|
type="url"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="https://example.coffee"
|
||||||
|
data-bind:_roaster-homepage
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,64 +98,131 @@
|
||||||
<!-- Roast: card (matched) -->
|
<!-- Roast: card (matched) -->
|
||||||
<div data-show="$_matchedRoastId">
|
<div data-show="$_matchedRoastId">
|
||||||
<h3 class="text-base font-semibold text-text">Roast</h3>
|
<h3 class="text-base font-semibold text-text">Roast</h3>
|
||||||
<div class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer" data-on:click="$_matchedRoastId = ''">
|
<div
|
||||||
|
class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between cursor-pointer"
|
||||||
|
data-on:click="$_matchedRoastId = ''"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{{ icons::coffee_bean("h-4 w-4 text-accent shrink-0") }}
|
{{ icons::coffee_bean("h-4 w-4 text-accent shrink-0") }}
|
||||||
<span class="font-medium text-text" data-text="$_roastName"></span>
|
<span class="font-medium text-text" data-text="$_roastName"></span>
|
||||||
<span class="text-xs text-text-muted" data-show="$_origin" data-text="$_origin" style="display:none"></span>
|
<span
|
||||||
|
class="text-xs text-text-muted"
|
||||||
|
data-show="$_origin"
|
||||||
|
data-text="$_origin"
|
||||||
|
style="display:none"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
|
<button
|
||||||
data-on:click="$_matchedRoastId = ''">{{ icons::pencil("h-3 w-3") }} Change</button>
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text"
|
||||||
|
data-on:click="$_matchedRoastId = ''"
|
||||||
|
>
|
||||||
|
{{ icons::pencil("h-3 w-3") }} Change
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Roast: form (not matched) -->
|
<!-- Roast: form (not matched) -->
|
||||||
<div data-show="!$_matchedRoastId" style="display:none">
|
<div data-show="!$_matchedRoastId" style="display:none">
|
||||||
<h3 class="text-base font-semibold text-text">Roast</h3>
|
<h3 class="text-base font-semibold text-text">Roast</h3>
|
||||||
<p class="mt-1 text-sm text-text-secondary">Details about this specific coffee.</p>
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
Details about this specific coffee.
|
||||||
|
</p>
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Roast Name *</span>
|
<span class="text-text">Roast Name *</span>
|
||||||
<input type="text" class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_roast-name />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Ethiopia Yirgacheffe"
|
||||||
|
data-bind:_roast-name
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Origin *</span>
|
<span class="text-text">Origin *</span>
|
||||||
<input type="text" class="input-field" placeholder="Ethiopia" data-bind:_origin />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Ethiopia"
|
||||||
|
data-bind:_origin
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Region *</span>
|
<span class="text-text">Region *</span>
|
||||||
<input type="text" class="input-field" placeholder="Guji" data-bind:_region />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Guji"
|
||||||
|
data-bind:_region
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Producer *</span>
|
<span class="text-text">Producer *</span>
|
||||||
<input type="text" class="input-field" placeholder="Chelbesa Cooperative" data-bind:_producer />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Chelbesa Cooperative"
|
||||||
|
data-bind:_producer
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Process *</span>
|
<span class="text-text">Process *</span>
|
||||||
<input type="text" class="input-field" placeholder="Washed" data-bind:_process />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Washed"
|
||||||
|
data-bind:_process
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Tasting Notes * (comma separated)</span>
|
<span class="text-text">Tasting Notes * (comma separated)</span>
|
||||||
<textarea rows="2" class="input-field" placeholder="Blueberry, Jasmine" data-bind:_tasting-notes></textarea>
|
<textarea
|
||||||
|
rows="2"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Blueberry, Jasmine"
|
||||||
|
data-bind:_tasting-notes
|
||||||
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<input type="checkbox" name="open_bag" value="true" class="accent-orange-700" data-bind:_open-bag />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="open_bag"
|
||||||
|
value="true"
|
||||||
|
class="accent-orange-700"
|
||||||
|
data-bind:_open-bag
|
||||||
|
/>
|
||||||
<span class="font-semibold text-text">Open a bag of this coffee</span>
|
<span class="font-semibold text-text">Open a bag of this coffee</span>
|
||||||
</label>
|
</label>
|
||||||
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
|
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Amount (grams)</span>
|
<span class="text-text">Amount (grams)</span>
|
||||||
<input type="number" name="bag_amount" min="1" step="any" class="input-field" placeholder="250" data-bind:_bag-amount />
|
<input
|
||||||
|
type="number"
|
||||||
|
name="bag_amount"
|
||||||
|
min="1"
|
||||||
|
step="any"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="250"
|
||||||
|
data-bind:_bag-amount
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p>
|
<p
|
||||||
<div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-accent">
|
data-show="$_scanError"
|
||||||
{{ icons::spinner("h-5 w-5") }}
|
data-text="$_scanError"
|
||||||
Saving…
|
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>
|
||||||
<div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
|
<div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
|
|
@ -130,7 +244,8 @@
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
data-show="$_matchedRoasterId && !$_matchedRoastId"
|
data-show="$_matchedRoasterId && !$_matchedRoastId"
|
||||||
class="inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover" style="display:none"
|
class="inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
|
||||||
|
style="display:none"
|
||||||
>
|
>
|
||||||
{{ icons::plus("h-4 w-4") }} Save Roast
|
{{ icons::plus("h-4 w-4") }} Save Roast
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -138,13 +253,18 @@
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
data-show="$_matchedRoastId && $_openBag"
|
data-show="$_matchedRoastId && $_openBag"
|
||||||
class="inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover" style="display:none"
|
class="inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
|
||||||
|
style="display:none"
|
||||||
>
|
>
|
||||||
{{ icons::bag("h-4 w-4") }} Open Bag
|
{{ icons::bag("h-4 w-4") }} Open Bag
|
||||||
</button>
|
</button>
|
||||||
<!-- Both matched + no bag: nothing to do -->
|
<!-- Both matched + no bag: nothing to do -->
|
||||||
<p data-show="$_matchedRoastId && !$_openBag" style="display:none"
|
<p
|
||||||
class="text-sm text-text-secondary">
|
data-show="$_matchedRoastId && !$_openBag"
|
||||||
This roast already exists. Check “Open a bag” above to add a new bag.
|
style="display:none"
|
||||||
|
class="text-sm text-text-secondary"
|
||||||
|
>
|
||||||
|
This roast already exists. Check “Open a bag” above to add a new
|
||||||
|
bag.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,19 @@
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate">{{ item.0 }}</span>
|
<span
|
||||||
|
class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate"
|
||||||
|
>{{ item.0 }}</span
|
||||||
|
>
|
||||||
<div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden">
|
<div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden">
|
||||||
<div class="h-full rounded bg-accent" style="width: {{ item.1 * 100 / max_count }}%"></div>
|
<div
|
||||||
|
class="h-full rounded bg-accent"
|
||||||
|
style="width: {{ item.1 * 100 / max_count }}%"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="w-6 shrink-0 text-xs text-text-muted text-right">{{ item.1 }}</span>
|
<span class="w-6 shrink-0 text-xs text-text-muted text-right"
|
||||||
|
>{{ item.1 }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -19,11 +27,19 @@
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate">{{ item.0 }}</span>
|
<span
|
||||||
|
class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate"
|
||||||
|
>{{ item.0 }}</span
|
||||||
|
>
|
||||||
<div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden">
|
<div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden">
|
||||||
<div class="h-full rounded bg-accent" style="width: {{ item.1 * 100.0 / max_weight }}%"></div>
|
<div
|
||||||
|
class="h-full rounded bg-accent"
|
||||||
|
style="width: {{ item.1 * 100.0 / max_weight }}%"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="w-14 shrink-0 text-xs text-text-muted text-right">{{ item.2 }}</span>
|
<span class="w-14 shrink-0 text-xs text-text-muted text-right"
|
||||||
|
>{{ item.2 }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,218 +1,525 @@
|
||||||
{% macro plus(class) %}
|
{% macro plus(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M11.25 3.75a1.25 1.25 0 1 0-2.5 0v4.5h-4.5a1.25 1.25 0 1 0 0 2.5h4.5v4.5a1.25 1.25 0 1 0 2.5 0v-4.5h4.5a1.25 1.25 0 1 0 0-2.5h-4.5v-4.5Z" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11.25 3.75a1.25 1.25 0 1 0-2.5 0v4.5h-4.5a1.25 1.25 0 1 0 0 2.5h4.5v4.5a1.25 1.25 0 1 0 2.5 0v-4.5h4.5a1.25 1.25 0 1 0 0-2.5h-4.5v-4.5Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro camera(class) %}
|
{% macro camera(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro spinner(class) %}
|
{% macro spinner(class) %}
|
||||||
<svg class="{{ class }} animate-spin text-accent" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
<svg
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
class="{{ class }} animate-spin text-accent"
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro delete(class) %}
|
{% macro delete(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro external_link(class) %}
|
{% macro external_link(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
<path d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z" />
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro location(class) %}
|
{% macro location(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003ZM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003ZM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro map(class) %}
|
{% macro map(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M8.157 2.176a1.5 1.5 0 0 0-1.147 0l-4.084 1.69A1.5 1.5 0 0 0 2 5.251v10.877a1.5 1.5 0 0 0 2.074 1.386l3.51-1.453 4.26 1.763a1.5 1.5 0 0 0 1.146 0l4.083-1.69A1.5 1.5 0 0 0 18 14.748V3.873a1.5 1.5 0 0 0-2.073-1.386l-3.51 1.452-4.26-1.763ZM7.58 5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 7.58 5Zm5.59 2.75a.75.75 0 0 0-1.5 0v6.5a.75.75 0 0 0 1.5 0v-6.5Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.157 2.176a1.5 1.5 0 0 0-1.147 0l-4.084 1.69A1.5 1.5 0 0 0 2 5.251v10.877a1.5 1.5 0 0 0 2.074 1.386l3.51-1.453 4.26 1.763a1.5 1.5 0 0 0 1.146 0l4.083-1.69A1.5 1.5 0 0 0 18 14.748V3.873a1.5 1.5 0 0 0-2.073-1.386l-3.51 1.452-4.26-1.763ZM7.58 5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 7.58 5Zm5.59 2.75a.75.75 0 0 0-1.5 0v6.5a.75.75 0 0 0 1.5 0v-6.5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro refresh(class) %}
|
{% macro refresh(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro bag(class) %}
|
{% macro bag(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M6 5v1H4.667a1.75 1.75 0 0 0-1.743 1.598l-.826 9.5A1.75 1.75 0 0 0 3.84 19H16.16a1.75 1.75 0 0 0 1.743-1.902l-.826-9.5A1.75 1.75 0 0 0 15.333 6H14V5a4 4 0 0 0-8 0Zm4-2.5A2.5 2.5 0 0 0 7.5 5v1h5V5A2.5 2.5 0 0 0 10 2.5ZM7.5 10a2.5 2.5 0 0 0 5 0V8.75a.75.75 0 0 1 1.5 0V10a4 4 0 0 1-8 0V8.75a.75.75 0 0 1 1.5 0V10Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M6 5v1H4.667a1.75 1.75 0 0 0-1.743 1.598l-.826 9.5A1.75 1.75 0 0 0 3.84 19H16.16a1.75 1.75 0 0 0 1.743-1.902l-.826-9.5A1.75 1.75 0 0 0 15.333 6H14V5a4 4 0 0 0-8 0Zm4-2.5A2.5 2.5 0 0 0 7.5 5v1h5V5A2.5 2.5 0 0 0 10 2.5ZM7.5 10a2.5 2.5 0 0 0 5 0V8.75a.75.75 0 0 1 1.5 0V10a4 4 0 0 1-8 0V8.75a.75.75 0 0 1 1.5 0V10Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro cup(class) %}
|
{% macro cup(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M7.25 2.75a.75.75 0 0 0-1.5 0C5.75 3.9 6.4 4.8 7 5.4c.35.35.5.7.5 1.1h-1a2.1 2.1 0 0 0-.8-1.6c-.45-.4-.95-1.1-.95-2.15ZM10.75 2.75a.75.75 0 0 0-1.5 0c0 1.15.65 2.05 1.25 2.65.35.35.5.7.5 1.1h-1a2.1 2.1 0 0 0-.8-1.6c-.45-.4-.95-1.1-.95-2.15Z" />
|
class="{{ class }}"
|
||||||
<path fill-rule="evenodd" d="M2.5 8.5A1.5 1.5 0 0 1 4 7h10a1.5 1.5 0 0 1 1.5 1.5v.5h.5a2.5 2.5 0 0 1 0 5h-.5v.5A3.5 3.5 0 0 1 12 18H6a3.5 3.5 0 0 1-3.5-3.5v-6Zm13 2v3h.5a1 1 0 1 0 0-2h-.25v-1h-.25ZM3.5 18.75a.75.75 0 0 1 .75-.75h9.5a.75.75 0 0 1 0 1.5h-9.5a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M7.25 2.75a.75.75 0 0 0-1.5 0C5.75 3.9 6.4 4.8 7 5.4c.35.35.5.7.5 1.1h-1a2.1 2.1 0 0 0-.8-1.6c-.45-.4-.95-1.1-.95-2.15ZM10.75 2.75a.75.75 0 0 0-1.5 0c0 1.15.65 2.05 1.25 2.65.35.35.5.7.5 1.1h-1a2.1 2.1 0 0 0-.8-1.6c-.45-.4-.95-1.1-.95-2.15Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M2.5 8.5A1.5 1.5 0 0 1 4 7h10a1.5 1.5 0 0 1 1.5 1.5v.5h.5a2.5 2.5 0 0 1 0 5h-.5v.5A3.5 3.5 0 0 1 12 18H6a3.5 3.5 0 0 1-3.5-3.5v-6Zm13 2v3h.5a1 1 0 1 0 0-2h-.25v-1h-.25ZM3.5 18.75a.75.75 0 0 1 .75-.75h9.5a.75.75 0 0 1 0 1.5h-9.5a.75.75 0 0 1-.75-.75Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro arrow_up(class) %}
|
{% macro arrow_up(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro arrow_down_tray(class) %}
|
{% macro arrow_down_tray(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z" />
|
class="{{ class }}"
|
||||||
<path d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z" />
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro arrow_up_tray(class) %}
|
{% macro arrow_up_tray(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M9.25 13.25a.75.75 0 0 0 1.5 0V4.636l2.955 3.129a.75.75 0 0 0 1.09-1.03l-4.25-4.5a.75.75 0 0 0-1.09 0l-4.25 4.5a.75.75 0 1 0 1.09 1.03L9.25 4.636v8.614Z" />
|
class="{{ class }}"
|
||||||
<path d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z" />
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.25 13.25a.75.75 0 0 0 1.5 0V4.636l2.955 3.129a.75.75 0 0 0 1.09-1.03l-4.25-4.5a.75.75 0 0 0-1.09 0l-4.25 4.5a.75.75 0 1 0 1.09 1.03L9.25 4.636v8.614Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro plus_circle(class) %}
|
{% macro plus_circle(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-11.25a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-11.25a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro logout(class) %}
|
{% macro logout(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 448 512" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M320 48a48 48 0 1 0-96 0 48 48 0 1 0 96 0zM125.7 175.5c9.9-9.9 23.4-15.5 37.5-15.5 1.9 0 3.8.1 5.6.3L137.6 254c-9.3 28 1.7 58.8 26.8 74.5l86.2 53.9-25.4 88.8c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l28.7-100.4c5.9-20.6-2.6-42.6-20.7-53.9L238 299l30.9-82.4 5.1 12.3C289 264.7 323.9 288 362.7 288h21.3c17.7 0 32-14.3 32-32s-14.3-32-32-32h-21.3c-12.9 0-24.6-7.8-29.5-19.7l-6.3-15c-14.6-35.1-44.1-61.9-80.5-73.1l-48.7-15c-11.1-3.4-22.7-5.2-34.4-5.2-31 0-60.8 12.3-82.7 34.3L57.4 153.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l23.1-23.1zM91.2 352H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h69.6c19 0 36.2-11.2 43.9-28.5L157 361.6l-9.5-6c-17.5-10.9-30.5-26.8-37.9-44.9L91.2 352z"/>
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 448 512"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M320 48a48 48 0 1 0-96 0 48 48 0 1 0 96 0zM125.7 175.5c9.9-9.9 23.4-15.5 37.5-15.5 1.9 0 3.8.1 5.6.3L137.6 254c-9.3 28 1.7 58.8 26.8 74.5l86.2 53.9-25.4 88.8c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l28.7-100.4c5.9-20.6-2.6-42.6-20.7-53.9L238 299l30.9-82.4 5.1 12.3C289 264.7 323.9 288 362.7 288h21.3c17.7 0 32-14.3 32-32s-14.3-32-32-32h-21.3c-12.9 0-24.6-7.8-29.5-19.7l-6.3-15c-14.6-35.1-44.1-61.9-80.5-73.1l-48.7-15c-11.1-3.4-22.7-5.2-34.4-5.2-31 0-60.8 12.3-82.7 34.3L57.4 153.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l23.1-23.1zM91.2 352H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h69.6c19 0 36.2-11.2 43.9-28.5L157 361.6l-9.5-6c-17.5-10.9-30.5-26.8-37.9-44.9L91.2 352z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro beaker(class) %}
|
{% macro beaker(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro coffee_bean(class) %}
|
{% macro coffee_bean(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M9.75 2.5C6 3.3 3.5 6.3 3.5 10S6 16.7 9.75 17.5C9 16 8.25 13.5 8.25 10S9 4 9.75 2.5Z" />
|
class="{{ class }}"
|
||||||
<path d="M10.25 2.5C14 3.3 16.5 6.3 16.5 10S14 16.7 10.25 17.5C11 16 11.75 13.5 11.75 10S11 4 10.25 2.5Z" />
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.75 2.5C6 3.3 3.5 6.3 3.5 10S6 16.7 9.75 17.5C9 16 8.25 13.5 8.25 10S9 4 9.75 2.5Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.25 2.5C14 3.3 16.5 6.3 16.5 10S14 16.7 10.25 17.5C11 16 11.75 13.5 11.75 10S11 4 10.25 2.5Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro fire(class) %}
|
{% macro fire(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M13.5 4.938a7 7 0 1 1-9.006 1.737c.202-.257.59-.218.793.039.278.352.594.672.943.954.332.269.786-.049.773-.476a5.977 5.977 0 0 1 .572-2.759 6.026 6.026 0 0 1 2.486-2.665c.247-.14.55-.016.677.238A6.967 6.967 0 0 0 13.5 4.938ZM14 12a4 4 0 0 1-4 4c-1.913 0-3.52-1.398-3.91-3.182-.093-.429.44-.643.814-.413a4.043 4.043 0 0 0 1.601.564c.303.038.531-.24.51-.544a5.975 5.975 0 0 1 1.315-4.192.447.447 0 0 1 .431-.16A4.001 4.001 0 0 1 14 12Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M13.5 4.938a7 7 0 1 1-9.006 1.737c.202-.257.59-.218.793.039.278.352.594.672.943.954.332.269.786-.049.773-.476a5.977 5.977 0 0 1 .572-2.759 6.026 6.026 0 0 1 2.486-2.665c.247-.14.55-.016.677.238A6.967 6.967 0 0 0 13.5 4.938ZM14 12a4 4 0 0 1-4 4c-1.913 0-3.52-1.398-3.91-3.182-.093-.429.44-.643.814-.413a4.043 4.043 0 0 0 1.601.564c.303.038.531-.24.51-.544a5.975 5.975 0 0 1 1.315-4.192.447.447 0 0 1 .431-.16A4.001 4.001 0 0 1 14 12Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro grinder(class) %}
|
{% macro grinder(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" />
|
<path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" />
|
||||||
<rect x="5" y="16.5" width="10" height="1.5" rx=".5" />
|
<rect x="5" y="16.5" width="10" height="1.5" rx=".5" />
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% macro ellipsis_vertical(class) %}
|
{% macro ellipsis_vertical(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM10 8.5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM11.5 15.5a1.5 1.5 0 10-3 0 1.5 1.5 0 003 0z" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM10 8.5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM11.5 15.5a1.5 1.5 0 10-3 0 1.5 1.5 0 003 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro x_circle(class) %}
|
{% macro x_circle(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro chevron_down(class) %}
|
{% macro chevron_down(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro chevron_up(class) %}
|
{% macro chevron_up(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M14.78 11.78a.75.75 0 0 1-1.06 0L10 8.06l-3.72 3.72a.75.75 0 1 1-1.06-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M14.78 11.78a.75.75 0 0 1-1.06 0L10 8.06l-3.72 3.72a.75.75 0 1 1-1.06-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro user(class) %}
|
{% macro user(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro sun(class) %}
|
{% macro sun(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M10 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 2ZM10 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10ZM15.657 4.343a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 0 1-1.06-1.06l1.06-1.06a.75.75 0 0 1 1.06 0ZM18 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 18 10ZM15.657 15.657a.75.75 0 0 1-1.06 0l-1.06-1.06a.75.75 0 1 1 1.06-1.06l1.06 1.06a.75.75 0 0 1 0 1.06ZM10 18a.75.75 0 0 1-.75-.75v-1.5a.75.75 0 0 1 1.5 0v1.5A.75.75 0 0 1 10 18ZM4.343 15.657a.75.75 0 0 1 0-1.06l1.06-1.06a.75.75 0 1 1 1.061 1.06l-1.06 1.06a.75.75 0 0 1-1.061 0ZM2 10a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 2 10ZM4.343 4.343a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.06-1.06a.75.75 0 0 1 0-1.061Z" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 2ZM10 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10ZM15.657 4.343a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 0 1-1.06-1.06l1.06-1.06a.75.75 0 0 1 1.06 0ZM18 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 18 10ZM15.657 15.657a.75.75 0 0 1-1.06 0l-1.06-1.06a.75.75 0 1 1 1.06-1.06l1.06 1.06a.75.75 0 0 1 0 1.06ZM10 18a.75.75 0 0 1-.75-.75v-1.5a.75.75 0 0 1 1.5 0v1.5A.75.75 0 0 1 10 18ZM4.343 15.657a.75.75 0 0 1 0-1.06l1.06-1.06a.75.75 0 1 1 1.061 1.06l-1.06 1.06a.75.75 0 0 1-1.061 0ZM2 10a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 2 10ZM4.343 4.343a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.06-1.06a.75.75 0 0 1 0-1.061Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro moon(class) %}
|
{% macro moon(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M7.455 2.004a.75.75 0 0 1 .26.77 7 7 0 0 0 9.958 7.967.75.75 0 0 1 1.067.853A8.5 8.5 0 1 1 6.647 1.921a.75.75 0 0 1 .808.083Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.455 2.004a.75.75 0 0 1 .26.77 7 7 0 0 0 9.958 7.967.75.75 0 0 1 1.067.853A8.5 8.5 0 1 1 6.647 1.921a.75.75 0 0 1 .808.083Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro check_circle(class) %}
|
{% macro check_circle(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro key(class) %}
|
{% macro key(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro terminal(class) %}
|
{% macro terminal(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M3.25 3A2.25 2.25 0 0 0 1 5.25v9.5A2.25 2.25 0 0 0 3.25 17h13.5A2.25 2.25 0 0 0 19 14.75v-9.5A2.25 2.25 0 0 0 16.75 3H3.25Zm.943 8.752a.75.75 0 0 1 .055-1.06L6.128 9l-1.88-1.693a.75.75 0 1 1 1.004-1.114l2.5 2.25a.75.75 0 0 1 0 1.114l-2.5 2.25a.75.75 0 0 1-1.06-.055ZM9.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M3.25 3A2.25 2.25 0 0 0 1 5.25v9.5A2.25 2.25 0 0 0 3.25 17h13.5A2.25 2.25 0 0 0 19 14.75v-9.5A2.25 2.25 0 0 0 16.75 3H3.25Zm.943 8.752a.75.75 0 0 1 .055-1.06L6.128 9l-1.88-1.693a.75.75 0 1 1 1.004-1.114l2.5 2.25a.75.75 0 0 1 0 1.114l-2.5 2.25a.75.75 0 0 1-1.06-.055ZM9.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro pencil(class) %}
|
{% macro pencil(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.885L17.5 5.5a2.121 2.121 0 0 0-3-3L3.58 13.42a4 4 0 0 0-.885 1.343Z" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2.695 14.763l-1.262 3.154a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.885L17.5 5.5a2.121 2.121 0 0 0-3-3L3.58 13.42a4 4 0 0 0-.885 1.343Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro clipboard(class) %}
|
{% macro clipboard(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M13.887 3.182c.396.037.79.08 1.183.128C16.194 3.45 17 4.414 17 5.517V16.75A2.25 2.25 0 0 1 14.75 19h-9.5A2.25 2.25 0 0 1 3 16.75V5.517c0-1.103.806-2.068 1.93-2.207.393-.048.787-.09 1.183-.128A3.001 3.001 0 0 1 9 1h2c1.373 0 2.531.923 2.887 2.182ZM7.5 4A1.5 1.5 0 0 1 9 2.5h2A1.5 1.5 0 0 1 12.5 4v.5h-5V4Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M13.887 3.182c.396.037.79.08 1.183.128C16.194 3.45 17 4.414 17 5.517V16.75A2.25 2.25 0 0 1 14.75 19h-9.5A2.25 2.25 0 0 1 3 16.75V5.517c0-1.103.806-2.068 1.93-2.207.393-.048.787-.09 1.183-.128A3.001 3.001 0 0 1 9 1h2c1.373 0 2.531.923 2.887 2.182ZM7.5 4A1.5 1.5 0 0 1 9 2.5h2A1.5 1.5 0 0 1 12.5 4v.5h-5V4Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro check(class) %}
|
{% macro check(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro x_mark(class) %}
|
{% macro x_mark(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro chevron_left(class) %}
|
{% macro chevron_left(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro chevron_right(class) %}
|
{% macro chevron_right(class) %}
|
||||||
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
class="{{ class }}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,22 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="rounded-lg border bg-surface"
|
<section
|
||||||
{% if bags.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(bags.next_page().unwrap())|safe }}" data-target="#bag-list"{% endif %}
|
class="rounded-lg border bg-surface"
|
||||||
|
{% if bags.has_next() %}
|
||||||
|
data-infinite-scroll
|
||||||
|
data-next-url="{{ navigator.fragment_page_href(bags.next_page().unwrap())|safe }}"
|
||||||
|
data-target="#bag-list"
|
||||||
|
{% endif %}
|
||||||
>
|
>
|
||||||
{{ table::search_header(navigator, "#bag-list") }}
|
{{ table::search_header(navigator, "#bag-list") }}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="responsive-table min-w-full divide-y text-left text-sm text-text">
|
<table
|
||||||
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary">
|
class="responsive-table min-w-full divide-y text-left text-sm text-text"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-surface-alt text-xs font-semibold text-text-secondary"
|
||||||
|
>
|
||||||
<tr>
|
<tr>
|
||||||
{{ table::sortable_header("Added", "created-at", navigator, "#bag-list") }}
|
{{ table::sortable_header("Added", "created-at", navigator, "#bag-list") }}
|
||||||
{{ table::sortable_header("Roaster", "roaster", navigator, "#bag-list") }}
|
{{ table::sortable_header("Roaster", "roaster", navigator, "#bag-list") }}
|
||||||
|
|
@ -33,44 +42,81 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y/70">
|
<tbody class="divide-y/70">
|
||||||
{% for bag in bags.items %}
|
{% for bag in bags.items %}
|
||||||
<tr class="transition hover:bg-surface-alt"
|
<tr
|
||||||
|
class="transition hover:bg-surface-alt"
|
||||||
onclick="window.location.href='/bags/{{ bag.id }}'"
|
onclick="window.location.href='/bags/{{ bag.id }}'"
|
||||||
>
|
>
|
||||||
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Added"
|
||||||
|
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
|
||||||
|
>
|
||||||
<div>{{ bag.created_date }}</div>
|
<div>{{ bag.created_date }}</div>
|
||||||
<div class="hidden md:block text-xs font-normal text-text-muted">{{ bag.created_time }}</div>
|
<div
|
||||||
|
class="hidden md:block text-xs font-normal text-text-muted"
|
||||||
|
>
|
||||||
|
{{ bag.created_time }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Roaster"
|
||||||
|
class="px-4 py-3 whitespace-nowrap font-medium text-text-secondary"
|
||||||
|
>
|
||||||
{{ bag.roaster_name }}
|
{{ bag.roaster_name }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Roast" class="card-title px-4 py-3 whitespace-nowrap">
|
<td
|
||||||
|
data-label="Roast"
|
||||||
|
class="card-title px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
{{ bag.roast_name }}
|
{{ bag.roast_name }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Status" class="mobile-hidden px-4 py-3 whitespace-nowrap">
|
<td
|
||||||
|
data-label="Status"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
{% if bag.closed %}
|
{% if bag.closed %}
|
||||||
<span class="text-text-muted">Closed</span>
|
<span class="text-text-muted">Closed</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div>
|
<div>
|
||||||
<div class="h-2.5 rounded-full bg-surface-alt overflow-hidden">
|
<div
|
||||||
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div>
|
class="h-2.5 rounded-full bg-surface-alt overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-accent"
|
||||||
|
style="width: {{ bag.used_percent }}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-xs text-text-muted">
|
||||||
|
{{ bag.remaining }} / {{ bag.amount }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% if !bag.closed %}
|
{% if !bag.closed %}
|
||||||
<td data-label="" class="card-progress md:hidden">
|
<td data-label="" class="card-progress md:hidden">
|
||||||
<div class="h-1.5 rounded-full bg-surface-alt overflow-hidden">
|
<div
|
||||||
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div>
|
class="h-1.5 rounded-full bg-surface-alt overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-accent"
|
||||||
|
style="width: {{ bag.used_percent }}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-right text-xs text-text-muted">
|
||||||
|
{{ bag.remaining }} / {{ bag.amount }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 text-right text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}</div>
|
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td data-label="Finished" class="mobile-hidden whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Finished"
|
||||||
|
class="mobile-hidden whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
|
||||||
|
>
|
||||||
<div>{{ bag.finished_date }}</div>
|
<div>{{ bag.finished_date }}</div>
|
||||||
</td>
|
</td>
|
||||||
{% if bag.closed %}
|
{% if bag.closed %}
|
||||||
<td data-label="Finished" class="whitespace-nowrap px-4 py-3 font-medium text-text-secondary md:hidden">
|
<td
|
||||||
|
data-label="Finished"
|
||||||
|
class="whitespace-nowrap px-4 py-3 font-medium text-text-secondary md:hidden"
|
||||||
|
>
|
||||||
<div>{{ bag.finished_date }}</div>
|
<div>{{ bag.finished_date }}</div>
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -81,8 +127,10 @@
|
||||||
<span class="text-text-muted">Closed</span>
|
<span class="text-text-muted">Closed</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<a href="/bags/{{ bag.id }}"
|
<a
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
|
href="/bags/{{ bag.id }}"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,11 +141,16 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if bags.items.is_empty() %}
|
{% if bags.items.is_empty() %}
|
||||||
<div class="p-8 text-center text-text-muted">No bags match the search.</div>
|
<div class="p-8 text-center text-text-muted">
|
||||||
|
No bags match the search.
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ table::pagination_header(bags, navigator, "#bag-list") }}
|
{{ table::pagination_header(bags, navigator, "#bag-list") }}
|
||||||
{% if bags.has_next() %}
|
{% if bags.has_next() %}
|
||||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
<div
|
||||||
|
class="infinite-scroll-sentinel h-4 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,23 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="rounded-lg border bg-surface"
|
<section
|
||||||
{% if brews.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(brews.next_page().unwrap())|safe }}" data-target="#brew-list"{% endif %}
|
class="rounded-lg border bg-surface"
|
||||||
|
{% if brews.has_next() %}
|
||||||
|
data-infinite-scroll
|
||||||
|
data-next-url="{{ navigator.fragment_page_href(brews.next_page().unwrap())|safe }}"
|
||||||
|
data-target="#brew-list"
|
||||||
|
{% endif %}
|
||||||
>
|
>
|
||||||
{{ table::search_header(navigator, "#brew-list") }}
|
{{ table::search_header(navigator, "#brew-list") }}
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="responsive-table min-w-full divide-y text-left text-sm text-text">
|
<table
|
||||||
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary">
|
class="responsive-table min-w-full divide-y text-left text-sm text-text"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-surface-alt text-xs font-semibold text-text-secondary"
|
||||||
|
>
|
||||||
<tr>
|
<tr>
|
||||||
{{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }}
|
{{ table::sortable_header("Added", "created-at", navigator, "#brew-list") }}
|
||||||
<th scope="col" class="px-4 py-3">Coffee</th>
|
<th scope="col" class="px-4 py-3">Coffee</th>
|
||||||
|
|
@ -35,39 +44,83 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y/70">
|
<tbody class="divide-y/70">
|
||||||
{% for brew in brews.items %}
|
{% for brew in brews.items %}
|
||||||
<tr class="transition hover:bg-surface-alt"
|
<tr
|
||||||
|
class="transition hover:bg-surface-alt"
|
||||||
onclick="window.location.href='/brews/{{ brew.id }}'"
|
onclick="window.location.href='/brews/{{ brew.id }}'"
|
||||||
>
|
>
|
||||||
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Added"
|
||||||
|
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
|
||||||
|
>
|
||||||
<div>{{ brew.created_date }}</div>
|
<div>{{ brew.created_date }}</div>
|
||||||
<div class="hidden md:block text-xs font-normal text-text-muted">{{ brew.created_time }}</div>
|
<div
|
||||||
|
class="hidden md:block text-xs font-normal text-text-muted"
|
||||||
|
>
|
||||||
|
{{ brew.created_time }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap">
|
<td
|
||||||
|
data-label="Coffee"
|
||||||
|
class="card-title px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
<div class="font-medium text-text">{{ brew.roast_name }}</div>
|
<div class="font-medium text-text">{{ brew.roast_name }}</div>
|
||||||
<div class="hidden md:block text-xs text-text-muted">{{ brew.roaster_name }}</div>
|
<div class="hidden md:block text-xs text-text-muted">
|
||||||
|
{{ brew.roaster_name }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="Roaster"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ brew.roaster_name }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.roaster_name }}</td>
|
|
||||||
<td data-label="Grind" class="px-4 py-3 whitespace-nowrap">
|
<td data-label="Grind" class="px-4 py-3 whitespace-nowrap">
|
||||||
<div>{{ brew.grind_setting }}</div>
|
<div>{{ brew.grind_setting }}</div>
|
||||||
<div class="hidden md:block text-xs text-text-muted">{{ brew.grinder_name }}</div>
|
<div class="hidden md:block text-xs text-text-muted">
|
||||||
|
{{ brew.grinder_name }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="Grinder"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ brew.grinder_name }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Grinder" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.grinder_name }}</td>
|
|
||||||
<td data-label="Recipe" class="px-4 py-3 whitespace-nowrap">
|
<td data-label="Recipe" class="px-4 py-3 whitespace-nowrap">
|
||||||
<div>{{ brew.coffee_weight }} · {{ brew.water_volume }}</div>
|
<div>{{ brew.coffee_weight }} · {{ brew.water_volume }}</div>
|
||||||
<div class="hidden md:block text-xs text-text-muted">{% if let Some(bt) = brew.brew_time %}{{ bt }} · {% endif %}{{ brew.water_temp }}</div>
|
<div class="hidden md:block text-xs text-text-muted">
|
||||||
|
{% if let Some(bt) = brew.brew_time %}{{ bt }} ·{% endif %}{{ brew.water_temp }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{% if let Some(bt) = brew.brew_time %}
|
{% if let Some(bt) = brew.brew_time %}
|
||||||
<td data-label="Brew Time" class="px-4 py-3 whitespace-nowrap md:hidden">{{ bt }}</td>
|
<td
|
||||||
|
data-label="Brew Time"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ bt }}
|
||||||
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td data-label="Temp" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.water_temp }}</td>
|
<td
|
||||||
|
data-label="Temp"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ brew.water_temp }}
|
||||||
|
</td>
|
||||||
<td data-label="Brewer" class="px-4 py-3 whitespace-nowrap">
|
<td data-label="Brewer" class="px-4 py-3 whitespace-nowrap">
|
||||||
<div>{{ brew.brewer_name }}</div>
|
<div>{{ brew.brewer_name }}</div>
|
||||||
{% if let Some(fp_name) = brew.filter_paper_name %}
|
{% if let Some(fp_name) = brew.filter_paper_name %}
|
||||||
<div class="hidden md:block text-xs text-text-muted">{{ fp_name }}</div>
|
<div class="hidden md:block text-xs text-text-muted">
|
||||||
|
{{ fp_name }}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% if let Some(fp_name) = brew.filter_paper_name %}
|
{% if let Some(fp_name) = brew.filter_paper_name %}
|
||||||
<td data-label="Filter" class="px-4 py-3 whitespace-nowrap md:hidden">{{ fp_name }}</td>
|
<td
|
||||||
|
data-label="Filter"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ fp_name }}
|
||||||
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td data-label="Notes" class="px-4 py-3">
|
<td data-label="Notes" class="px-4 py-3">
|
||||||
{% if !brew.quick_notes.is_empty() %}
|
{% if !brew.quick_notes.is_empty() %}
|
||||||
|
|
@ -81,8 +134,10 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||||
<a href="/brews/{{ brew.id }}"
|
<a
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
|
href="/brews/{{ brew.id }}"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -101,7 +156,10 @@
|
||||||
{{ table::pagination_header(brews, navigator, "#brew-list") }}
|
{{ table::pagination_header(brews, navigator, "#brew-list") }}
|
||||||
|
|
||||||
{% if brews.has_next() %}
|
{% if brews.has_next() %}
|
||||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
<div
|
||||||
|
class="infinite-scroll-sentinel h-4 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,22 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="rounded-lg border bg-surface"
|
<section
|
||||||
{% if cafes.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(cafes.next_page().unwrap())|safe }}" data-target="#cafe-list"{% endif %}
|
class="rounded-lg border bg-surface"
|
||||||
|
{% if cafes.has_next() %}
|
||||||
|
data-infinite-scroll
|
||||||
|
data-next-url="{{ navigator.fragment_page_href(cafes.next_page().unwrap())|safe }}"
|
||||||
|
data-target="#cafe-list"
|
||||||
|
{% endif %}
|
||||||
>
|
>
|
||||||
{{ table::search_header(navigator, "#cafe-list") }}
|
{{ table::search_header(navigator, "#cafe-list") }}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="responsive-table min-w-full divide-y text-left text-sm text-text">
|
<table
|
||||||
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary">
|
class="responsive-table min-w-full divide-y text-left text-sm text-text"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-surface-alt text-xs font-semibold text-text-secondary"
|
||||||
|
>
|
||||||
<tr>
|
<tr>
|
||||||
{{ table::sortable_header("Added", "created-at", navigator, "#cafe-list") }}
|
{{ table::sortable_header("Added", "created-at", navigator, "#cafe-list") }}
|
||||||
{{ table::sortable_header("Name", "name", navigator, "#cafe-list") }}
|
{{ table::sortable_header("Name", "name", navigator, "#cafe-list") }}
|
||||||
|
|
@ -41,20 +50,52 @@
|
||||||
class="transition hover:bg-surface-alt"
|
class="transition hover:bg-surface-alt"
|
||||||
onclick="window.location.href='{{ cafe.detail_path }}'"
|
onclick="window.location.href='{{ cafe.detail_path }}'"
|
||||||
>
|
>
|
||||||
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Added"
|
||||||
|
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
|
||||||
|
>
|
||||||
<div>{{ cafe.created_date }}</div>
|
<div>{{ cafe.created_date }}</div>
|
||||||
<div class="hidden md:block text-xs font-normal text-text-muted">{{ cafe.created_time }}</div>
|
<div
|
||||||
|
class="hidden md:block text-xs font-normal text-text-muted"
|
||||||
|
>
|
||||||
|
{{ cafe.created_time }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Name" class="card-title px-4 py-3 font-medium text-text">
|
<td
|
||||||
|
data-label="Name"
|
||||||
|
class="card-title px-4 py-3 font-medium text-text"
|
||||||
|
>
|
||||||
{{ cafe.name }}
|
{{ cafe.name }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Location" class="px-4 py-3 whitespace-nowrap md:hidden">{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }}</td>
|
<td
|
||||||
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cafe.city }}</td>
|
data-label="Location"
|
||||||
<td data-label="Country" class="mobile-hidden px-4 py-3 whitespace-nowrap">{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }}</td>
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
<td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cafe.city }}</td>
|
>
|
||||||
|
{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="City"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ cafe.city }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="Country"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="City"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ cafe.city }}
|
||||||
|
</td>
|
||||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||||
<a href="{{ cafe.detail_path }}"
|
<a
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
|
href="{{ cafe.detail_path }}"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -64,11 +105,16 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if cafes.items.is_empty() %}
|
{% if cafes.items.is_empty() %}
|
||||||
<div class="p-8 text-center text-text-muted">No cafes match the search.</div>
|
<div class="p-8 text-center text-text-muted">
|
||||||
|
No cafes match the search.
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ table::pagination_header(cafes, navigator, "#cafe-list") }}
|
{{ table::pagination_header(cafes, navigator, "#cafe-list") }}
|
||||||
{% if cafes.has_next() %}
|
{% if cafes.has_next() %}
|
||||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
<div
|
||||||
|
class="infinite-scroll-sentinel h-4 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,22 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="rounded-lg border bg-surface"
|
<section
|
||||||
{% if cups.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(cups.next_page().unwrap())|safe }}" data-target="#cup-list"{% endif %}
|
class="rounded-lg border bg-surface"
|
||||||
|
{% if cups.has_next() %}
|
||||||
|
data-infinite-scroll
|
||||||
|
data-next-url="{{ navigator.fragment_page_href(cups.next_page().unwrap())|safe }}"
|
||||||
|
data-target="#cup-list"
|
||||||
|
{% endif %}
|
||||||
>
|
>
|
||||||
{{ table::search_header(navigator, "#cup-list") }}
|
{{ table::search_header(navigator, "#cup-list") }}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="responsive-table min-w-full divide-y text-left text-sm text-text">
|
<table
|
||||||
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary">
|
class="responsive-table min-w-full divide-y text-left text-sm text-text"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-surface-alt text-xs font-semibold text-text-secondary"
|
||||||
|
>
|
||||||
<tr>
|
<tr>
|
||||||
{{ table::sortable_header("Added", "created-at", navigator, "#cup-list") }}
|
{{ table::sortable_header("Added", "created-at", navigator, "#cup-list") }}
|
||||||
{{ table::sortable_header("Roast", "roast", navigator, "#cup-list") }}
|
{{ table::sortable_header("Roast", "roast", navigator, "#cup-list") }}
|
||||||
|
|
@ -38,24 +47,61 @@
|
||||||
class="transition hover:bg-surface-alt"
|
class="transition hover:bg-surface-alt"
|
||||||
onclick="window.location.href='/cups/{{ cup.id }}'"
|
onclick="window.location.href='/cups/{{ cup.id }}'"
|
||||||
>
|
>
|
||||||
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Added"
|
||||||
|
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
|
||||||
|
>
|
||||||
<div>{{ cup.created_date }}</div>
|
<div>{{ cup.created_date }}</div>
|
||||||
<div class="hidden md:block text-xs font-normal text-text-muted">{{ cup.created_time }}</div>
|
<div
|
||||||
|
class="hidden md:block text-xs font-normal text-text-muted"
|
||||||
|
>
|
||||||
|
{{ cup.created_time }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap md:hidden">
|
<td
|
||||||
|
data-label="Coffee"
|
||||||
|
class="card-title px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
<div class="font-medium text-text">{{ cup.roast_name }}</div>
|
<div class="font-medium text-text">{{ cup.roast_name }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cup.roaster_name }}</td>
|
<td
|
||||||
<td data-label="Roast" class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-text">{{ cup.roast_name }}</td>
|
data-label="Roaster"
|
||||||
<td data-label="Roaster" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cup.roaster_name }}</td>
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ cup.roaster_name }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="Roast"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-text"
|
||||||
|
>
|
||||||
|
{{ cup.roast_name }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="Roaster"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ cup.roaster_name }}
|
||||||
|
</td>
|
||||||
<td data-label="Cafe" class="px-4 py-3 whitespace-nowrap">
|
<td data-label="Cafe" class="px-4 py-3 whitespace-nowrap">
|
||||||
{{ cup.cafe_name }}
|
{{ cup.cafe_name }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cup.cafe_city }}</td>
|
<td
|
||||||
<td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cup.cafe_city }}</td>
|
data-label="City"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ cup.cafe_city }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="City"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ cup.cafe_city }}
|
||||||
|
</td>
|
||||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||||
<a href="/cups/{{ cup.id }}"
|
<a
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
|
href="/cups/{{ cup.id }}"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -65,11 +111,16 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if cups.items.is_empty() %}
|
{% if cups.items.is_empty() %}
|
||||||
<div class="p-8 text-center text-text-muted">No cups match the search.</div>
|
<div class="p-8 text-center text-text-muted">
|
||||||
|
No cups match the search.
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ table::pagination_header(cups, navigator, "#cup-list") }}
|
{{ table::pagination_header(cups, navigator, "#cup-list") }}
|
||||||
{% if cups.has_next() %}
|
{% if cups.has_next() %}
|
||||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
<div
|
||||||
|
class="infinite-scroll-sentinel h-4 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,23 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="rounded-lg border bg-surface"
|
<section
|
||||||
{% if gear.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(gear.next_page().unwrap())|safe }}" data-target="#gear-list"{% endif %}
|
class="rounded-lg border bg-surface"
|
||||||
|
{% if gear.has_next() %}
|
||||||
|
data-infinite-scroll
|
||||||
|
data-next-url="{{ navigator.fragment_page_href(gear.next_page().unwrap())|safe }}"
|
||||||
|
data-target="#gear-list"
|
||||||
|
{% endif %}
|
||||||
>
|
>
|
||||||
{{ table::search_header(navigator, "#gear-list") }}
|
{{ table::search_header(navigator, "#gear-list") }}
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="responsive-table min-w-full divide-y text-left text-sm text-text">
|
<table
|
||||||
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary">
|
class="responsive-table min-w-full divide-y text-left text-sm text-text"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-surface-alt text-xs font-semibold text-text-secondary"
|
||||||
|
>
|
||||||
<tr>
|
<tr>
|
||||||
{{ table::sortable_header("Added", "created-at", navigator, "#gear-list") }}
|
{{ table::sortable_header("Added", "created-at", navigator, "#gear-list") }}
|
||||||
{{ table::sortable_header("Category", "category", navigator, "#gear-list") }}
|
{{ table::sortable_header("Category", "category", navigator, "#gear-list") }}
|
||||||
|
|
@ -33,26 +42,47 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y/70">
|
<tbody class="divide-y/70">
|
||||||
{% for item in gear.items %}
|
{% for item in gear.items %}
|
||||||
<tr class="transition hover:bg-surface-alt"
|
<tr
|
||||||
|
class="transition hover:bg-surface-alt"
|
||||||
onclick="window.location.href='/gear/{{ item.id }}'"
|
onclick="window.location.href='/gear/{{ item.id }}'"
|
||||||
>
|
>
|
||||||
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Added"
|
||||||
|
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
|
||||||
|
>
|
||||||
<div>{{ item.created_date }}</div>
|
<div>{{ item.created_date }}</div>
|
||||||
<div class="hidden md:block text-xs font-normal text-text-muted">{{ item.created_time }}</div>
|
<div
|
||||||
|
class="hidden md:block text-xs font-normal text-text-muted"
|
||||||
|
>
|
||||||
|
{{ item.created_time }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label=""
|
||||||
|
class="card-title px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ item.make }} {{ item.model }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="" class="card-title px-4 py-3 whitespace-nowrap md:hidden">{{ item.make }} {{ item.model }}</td>
|
|
||||||
<td data-label="Category" class="px-4 py-3 whitespace-nowrap">
|
<td data-label="Category" class="px-4 py-3 whitespace-nowrap">
|
||||||
{{ item.category_label }}
|
{{ item.category_label }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Make" class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Make"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-text-secondary"
|
||||||
|
>
|
||||||
{{ item.make }}
|
{{ item.make }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Model" class="mobile-hidden px-4 py-3 whitespace-nowrap">
|
<td
|
||||||
|
data-label="Model"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
{{ item.model }}
|
{{ item.model }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||||
<a href="/gear/{{ item.id }}"
|
<a
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
|
href="/gear/{{ item.id }}"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -62,11 +92,16 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if gear.items.is_empty() %}
|
{% if gear.items.is_empty() %}
|
||||||
<div class="p-8 text-center text-text-muted">No gear matches the search.</div>
|
<div class="p-8 text-center text-text-muted">
|
||||||
|
No gear matches the search.
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ table::pagination_header(gear, navigator, "#gear-list") }}
|
{{ table::pagination_header(gear, navigator, "#gear-list") }}
|
||||||
{% if gear.has_next() %}
|
{% if gear.has_next() %}
|
||||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
<div
|
||||||
|
class="infinite-scroll-sentinel h-4 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,22 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="rounded-lg border bg-surface"
|
<section
|
||||||
{% if roasts.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(roasts.next_page().unwrap())|safe }}" data-target="#roast-list"{% endif %}
|
class="rounded-lg border bg-surface"
|
||||||
|
{% if roasts.has_next() %}
|
||||||
|
data-infinite-scroll
|
||||||
|
data-next-url="{{ navigator.fragment_page_href(roasts.next_page().unwrap())|safe }}"
|
||||||
|
data-target="#roast-list"
|
||||||
|
{% endif %}
|
||||||
>
|
>
|
||||||
{{ table::search_header(navigator, "#roast-list") }}
|
{{ table::search_header(navigator, "#roast-list") }}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="responsive-table min-w-full divide-y text-left text-sm text-text">
|
<table
|
||||||
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary">
|
class="responsive-table min-w-full divide-y text-left text-sm text-text"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-surface-alt text-xs font-semibold text-text-secondary"
|
||||||
|
>
|
||||||
<tr>
|
<tr>
|
||||||
{{ table::sortable_header("Added", "created-at", navigator, "#roast-list") }}
|
{{ table::sortable_header("Added", "created-at", navigator, "#roast-list") }}
|
||||||
{{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
|
{{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
|
||||||
|
|
@ -42,29 +51,54 @@
|
||||||
class="transition hover:bg-surface-alt"
|
class="transition hover:bg-surface-alt"
|
||||||
onclick="window.location.href='{{ roast.detail_path }}'"
|
onclick="window.location.href='{{ roast.detail_path }}'"
|
||||||
>
|
>
|
||||||
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Added"
|
||||||
|
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
|
||||||
|
>
|
||||||
<div>{{ roast.created_date }}</div>
|
<div>{{ roast.created_date }}</div>
|
||||||
<div class="hidden md:block text-xs font-normal text-text-muted">{{ roast.created_time }}</div>
|
<div
|
||||||
|
class="hidden md:block text-xs font-normal text-text-muted"
|
||||||
|
>
|
||||||
|
{{ roast.created_time }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Roast" class="card-title px-4 py-3">
|
<td data-label="Roast" class="card-title px-4 py-3">
|
||||||
<div class="font-medium text-text">{{ roast.name }}</div>
|
<div class="font-medium text-text">{{ roast.name }}</div>
|
||||||
<div class="hidden md:block text-xs text-text-muted">{{ roast.roaster_label }}</div>
|
<div class="hidden md:block text-xs text-text-muted">
|
||||||
|
{{ roast.roaster_label }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="Roaster"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ roast.roaster_label }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roast.roaster_label }}</td>
|
|
||||||
<td data-label="Origin" class="px-4 py-3 whitespace-nowrap">
|
<td data-label="Origin" class="px-4 py-3 whitespace-nowrap">
|
||||||
{% if !roast.origin_flag.is_empty() %}{{ roast.origin_flag }}{% endif %}{{ roast.origin }}
|
{% if !roast.origin_flag.is_empty() %}{{ roast.origin_flag }}{% endif %}{{ roast.origin }}
|
||||||
{% if !roast.producer.is_empty() %}
|
{% if !roast.producer.is_empty() %}
|
||||||
<div class="hidden md:block text-xs text-text-muted">{{ roast.producer }}</div>
|
<div class="hidden md:block text-xs text-text-muted">
|
||||||
|
{{ roast.producer }}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% if !roast.producer.is_empty() %}
|
{% if !roast.producer.is_empty() %}
|
||||||
<td data-label="Producer" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roast.producer }}</td>
|
<td
|
||||||
|
data-label="Producer"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ roast.producer }}
|
||||||
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td data-label="Tasting Notes" class="px-4 py-3">
|
<td data-label="Tasting Notes" class="px-4 py-3">
|
||||||
{% if !roast.tasting_notes.is_empty() %}
|
{% if !roast.tasting_notes.is_empty() %}
|
||||||
<div class="flex flex-wrap gap-1 md:justify-start justify-end">
|
<div
|
||||||
|
class="flex flex-wrap gap-1 md:justify-start justify-end"
|
||||||
|
>
|
||||||
{% for note in roast.tasting_notes %}
|
{% for note in roast.tasting_notes %}
|
||||||
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
<span class="{{ note.pill_class }}"
|
||||||
|
>{{ note.label }}</span
|
||||||
|
>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -72,8 +106,10 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||||
<a href="{{ roast.detail_path }}"
|
<a
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
|
href="{{ roast.detail_path }}"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -83,11 +119,16 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if roasts.items.is_empty() %}
|
{% if roasts.items.is_empty() %}
|
||||||
<div class="p-8 text-center text-text-muted">No roasts match the search.</div>
|
<div class="p-8 text-center text-text-muted">
|
||||||
|
No roasts match the search.
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ table::pagination_header(roasts, navigator, "#roast-list") }}
|
{{ table::pagination_header(roasts, navigator, "#roast-list") }}
|
||||||
{% if roasts.has_next() %}
|
{% if roasts.has_next() %}
|
||||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
<div
|
||||||
|
class="infinite-scroll-sentinel h-4 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,22 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="rounded-lg border bg-surface"
|
<section
|
||||||
{% if roasters.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(roasters.next_page().unwrap())|safe }}" data-target="#roaster-list"{% endif %}
|
class="rounded-lg border bg-surface"
|
||||||
|
{% if roasters.has_next() %}
|
||||||
|
data-infinite-scroll
|
||||||
|
data-next-url="{{ navigator.fragment_page_href(roasters.next_page().unwrap())|safe }}"
|
||||||
|
data-target="#roaster-list"
|
||||||
|
{% endif %}
|
||||||
>
|
>
|
||||||
{{ table::search_header(navigator, "#roaster-list") }}
|
{{ table::search_header(navigator, "#roaster-list") }}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="responsive-table min-w-full divide-y text-left text-sm text-text">
|
<table
|
||||||
<thead class="bg-surface-alt text-xs font-semibold text-text-secondary">
|
class="responsive-table min-w-full divide-y text-left text-sm text-text"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-surface-alt text-xs font-semibold text-text-secondary"
|
||||||
|
>
|
||||||
<tr>
|
<tr>
|
||||||
{{ table::sortable_header("Added", "created-at", navigator, "#roaster-list") }}
|
{{ table::sortable_header("Added", "created-at", navigator, "#roaster-list") }}
|
||||||
{{ table::sortable_header("Name", "name", navigator, "#roaster-list") }}
|
{{ table::sortable_header("Name", "name", navigator, "#roaster-list") }}
|
||||||
|
|
@ -41,22 +50,54 @@
|
||||||
class="transition hover:bg-surface-alt"
|
class="transition hover:bg-surface-alt"
|
||||||
onclick="window.location.href='{{ roaster.detail_path }}'"
|
onclick="window.location.href='{{ roaster.detail_path }}'"
|
||||||
>
|
>
|
||||||
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
|
<td
|
||||||
|
data-label="Added"
|
||||||
|
class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"
|
||||||
|
>
|
||||||
<div>{{ roaster.created_date }}</div>
|
<div>{{ roaster.created_date }}</div>
|
||||||
<div class="hidden md:block text-xs font-normal text-text-muted">{{ roaster.created_time }}</div>
|
<div
|
||||||
|
class="hidden md:block text-xs font-normal text-text-muted"
|
||||||
|
>
|
||||||
|
{{ roaster.created_time }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Name" class="card-title px-4 py-3 font-medium text-text">
|
<td
|
||||||
|
data-label="Name"
|
||||||
|
class="card-title px-4 py-3 font-medium text-text"
|
||||||
|
>
|
||||||
{{ roaster.name }}
|
{{ roaster.name }}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Location" class="px-4 py-3 whitespace-nowrap md:hidden">{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}</td>
|
<td
|
||||||
|
data-label="Location"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }}
|
||||||
|
</td>
|
||||||
{% if !roaster.city.is_empty() %}
|
{% if !roaster.city.is_empty() %}
|
||||||
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roaster.city }}</td>
|
<td
|
||||||
|
data-label="City"
|
||||||
|
class="px-4 py-3 whitespace-nowrap md:hidden"
|
||||||
|
>
|
||||||
|
{{ roaster.city }}
|
||||||
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td data-label="Country" class="mobile-hidden px-4 py-3 whitespace-nowrap">{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}</td>
|
<td
|
||||||
<td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ roaster.city }}</td>
|
data-label="Country"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }}{% endif %}{{ roaster.country }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label="City"
|
||||||
|
class="mobile-hidden px-4 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ roaster.city }}
|
||||||
|
</td>
|
||||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||||
<a href="{{ roaster.detail_path }}"
|
<a
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
|
href="{{ roaster.detail_path }}"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-5 w-5") }}
|
{{ icons::chevron_right("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -66,11 +107,16 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if roasters.items.is_empty() %}
|
{% if roasters.items.is_empty() %}
|
||||||
<div class="p-8 text-center text-text-muted">No roasters match the search.</div>
|
<div class="p-8 text-center text-text-muted">
|
||||||
|
No roasters match the search.
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ table::pagination_header(roasters, navigator, "#roaster-list") }}
|
{{ table::pagination_header(roasters, navigator, "#roaster-list") }}
|
||||||
{% if roasters.has_next() %}
|
{% if roasters.has_next() %}
|
||||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
<div
|
||||||
|
class="infinite-scroll-sentinel h-4 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
class="input-field w-full text-sm"
|
class="input-field w-full text-sm"
|
||||||
data-search-url="{{ navigator.search_href_prefix()|safe }}"
|
data-search-url="{{ navigator.search_href_prefix()|safe }}"
|
||||||
data-on:input__debounce.300ms="history.pushState(null, '', el.dataset.searchUrl + el.value); @get(el.dataset.searchUrl + el.value, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
|
data-on:input__debounce.300ms="history.pushState(null, '', el.dataset.searchUrl + el.value); @get(el.dataset.searchUrl + el.value, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
|
||||||
{% if navigator.has_search() %}data-init="el.focus(); el.selectionStart = el.value.length"{% endif %}
|
{% if navigator.has_search() %}
|
||||||
|
data-init="el.focus(); el.selectionStart = el.value.length"
|
||||||
|
{% endif %}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
@ -17,8 +19,8 @@
|
||||||
class="pagination-controls hidden md:flex flex-wrap items-center justify-between gap-x-3 border-t px-4 py-1.5 text-xs leading-none text-text-secondary"
|
class="pagination-controls hidden md:flex flex-wrap items-center justify-between gap-x-3 border-t px-4 py-1.5 text-xs leading-none text-text-secondary"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
Showing {{ items.start_index() }}–{{ items.end_index() }} of {{ items.total }}
|
Showing {{ items.start_index() }}–{{ items.end_index() }} of
|
||||||
results
|
{{ items.total }} results
|
||||||
</span>
|
</span>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -26,7 +28,11 @@
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
|
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
{% if items.has_previous() %}
|
{% if items.has_previous() %}
|
||||||
data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.previous_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.previous_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
|
data-on:click="history.pushState(null, '',
|
||||||
|
'{{ navigator.page_href(items.previous_page().unwrap())|safe }}');
|
||||||
|
@get('{{ navigator.fragment_page_href(items.previous_page().unwrap())|safe }}',
|
||||||
|
{responseOverrides: {selector: '{{ target_selector }}', mode:
|
||||||
|
'replace'}})"
|
||||||
{% else %}
|
{% else %}
|
||||||
disabled
|
disabled
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -45,7 +51,11 @@
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
|
class="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold text-accent transition hover:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
{% if items.has_next() %}
|
{% if items.has_next() %}
|
||||||
data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.next_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.next_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
|
data-on:click="history.pushState(null, '',
|
||||||
|
'{{ navigator.page_href(items.next_page().unwrap())|safe }}');
|
||||||
|
@get('{{ navigator.fragment_page_href(items.next_page().unwrap())|safe }}',
|
||||||
|
{responseOverrides: {selector: '{{ target_selector }}', mode:
|
||||||
|
'replace'}})"
|
||||||
{% else %}
|
{% else %}
|
||||||
disabled
|
disabled
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -54,17 +64,54 @@
|
||||||
<span aria-hidden="true">→</span>
|
<span aria-hidden="true">→</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 text-xs font-semibold text-text-secondary">
|
<label
|
||||||
|
class="flex items-center gap-2 text-xs font-semibold text-text-secondary"
|
||||||
|
>
|
||||||
<span>Rows</span>
|
<span>Rows</span>
|
||||||
<select
|
<select
|
||||||
class="rounded-md border bg-surface px-2 py-1 text-xs font-semibold text-accent transition hover:border-accent"
|
class="rounded-md border bg-surface px-2 py-1 text-xs font-semibold text-accent transition hover:border-accent"
|
||||||
data-on:change="history.pushState(null, '', evt.target.selectedOptions[0].dataset.url); @get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
|
data-on:change="history.pushState(null, '', evt.target.selectedOptions[0].dataset.url); @get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
|
||||||
>
|
>
|
||||||
<option value="5" data-href="{{ navigator.fragment_rows_href("5")|safe }}" data-url="{{ navigator.rows_href("5")|safe }}" {% if items.is_page_size(5) %}selected{% endif %}>5</option>
|
<option
|
||||||
<option value="10" data-href="{{ navigator.fragment_rows_href("10")|safe }}" data-url="{{ navigator.rows_href("10")|safe }}" {% if items.is_page_size(10) %}selected{% endif %}>10</option>
|
value="5"
|
||||||
<option value="20" data-href="{{ navigator.fragment_rows_href("20")|safe }}" data-url="{{ navigator.rows_href("20")|safe }}" {% if items.is_page_size(20) %}selected{% endif %}>20</option>
|
data-href="{{ navigator.fragment_rows_href("5")|safe }}"
|
||||||
<option value="50" data-href="{{ navigator.fragment_rows_href("50")|safe }}" data-url="{{ navigator.rows_href("50")|safe }}" {% if items.is_page_size(50) %}selected{% endif %}>50</option>
|
data-url="{{ navigator.rows_href("5")|safe }}"
|
||||||
<option value="all" data-href="{{ navigator.fragment_rows_href("all")|safe }}" data-url="{{ navigator.rows_href("all")|safe }}" {% if items.is_showing_all() %}selected{% endif %}>All</option>
|
{% if items.is_page_size(5) %}selected{% endif %}
|
||||||
|
>
|
||||||
|
5
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="10"
|
||||||
|
data-href="{{ navigator.fragment_rows_href("10")|safe }}"
|
||||||
|
data-url="{{ navigator.rows_href("10")|safe }}"
|
||||||
|
{% if items.is_page_size(10) %}selected{% endif %}
|
||||||
|
>
|
||||||
|
10
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="20"
|
||||||
|
data-href="{{ navigator.fragment_rows_href("20")|safe }}"
|
||||||
|
data-url="{{ navigator.rows_href("20")|safe }}"
|
||||||
|
{% if items.is_page_size(20) %}selected{% endif %}
|
||||||
|
>
|
||||||
|
20
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="50"
|
||||||
|
data-href="{{ navigator.fragment_rows_href("50")|safe }}"
|
||||||
|
data-url="{{ navigator.rows_href("50")|safe }}"
|
||||||
|
{% if items.is_page_size(50) %}selected{% endif %}
|
||||||
|
>
|
||||||
|
50
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="all"
|
||||||
|
data-href="{{ navigator.fragment_rows_href("all")|safe }}"
|
||||||
|
data-url="{{ navigator.rows_href("all")|safe }}"
|
||||||
|
{% if items.is_showing_all() %}selected{% endif %}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,29 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
|
||||||
{% macro location_search_js() %}
|
{% macro location_search_js() %}
|
||||||
const locateUser = (triggerEl) => {
|
const locateUser = (triggerEl) => { const root =
|
||||||
const root = triggerEl.closest('[data-location-root]');
|
triggerEl.closest('[data-location-root]'); const emit = (name, detail) =>
|
||||||
const emit = (name, detail) =>
|
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); if
|
||||||
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
|
(!navigator.geolocation) { emit('location-error', { message: 'Geolocation not
|
||||||
|
supported by this browser.' }); return; } emit('location-start');
|
||||||
if (!navigator.geolocation) {
|
navigator.geolocation.getCurrentPosition( (pos) => { emit('location-found', {
|
||||||
emit('location-error', { message: 'Geolocation not supported by this browser.' });
|
lat: pos.coords.latitude, lng: pos.coords.longitude }); }, (err) => { if
|
||||||
return;
|
(err.code === 1) { emit('location-error', { message: 'Location access denied.
|
||||||
}
|
Search by name instead.' }); } else { emit('location-error', { message: 'Could
|
||||||
|
not determine location. Search by name instead.' }); } }, {
|
||||||
emit('location-start');
|
enableHighAccuracy: true, timeout: 15000 }, ); };
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
(pos) => {
|
|
||||||
emit('location-found', { lat: pos.coords.latitude, lng: pos.coords.longitude });
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
if (err.code === 1) {
|
|
||||||
emit('location-error', { message: 'Location access denied. Search by name instead.' });
|
|
||||||
} else {
|
|
||||||
emit('location-error', { message: 'Could not determine location. Search by name instead.' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ enableHighAccuracy: true, timeout: 15000 },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro location_search(error_signal) %}
|
{% macro location_search(error_signal) %}
|
||||||
<div data-location-root
|
<div
|
||||||
|
data-location-root
|
||||||
data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; @get('/api/v1/nearby-cafes?lat=' + evt.detail.lat + '&lng=' + evt.detail.lng + '&q=coffee', {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
|
data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; @get('/api/v1/nearby-cafes?lat=' + evt.detail.lat + '&lng=' + evt.detail.lng + '&q=coffee', {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
|
||||||
data-on:location-error="$_locating = false; {{ error_signal }} = evt.detail.message"
|
data-on:location-error="$_locating = false; {{ error_signal }} = evt.detail.message"
|
||||||
data-on:location-start="$_locating = true"
|
data-on:location-start="$_locating = true"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
data-on:click="locateUser(el)"
|
data-on:click="locateUser(el)"
|
||||||
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 transition hover:bg-surface-alt"
|
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 transition hover:bg-surface-alt"
|
||||||
data-class="{'text-accent': $_locationFound, 'text-text': !$_locationFound}"
|
data-class="{'text-accent': $_locationFound, 'text-text': !$_locationFound}"
|
||||||
|
|
@ -44,20 +31,29 @@ const locateUser = (triggerEl) => {
|
||||||
aria-label="Use my location"
|
aria-label="Use my location"
|
||||||
>
|
>
|
||||||
<span data-show="!$_locating">{{ icons::location("h-5 w-5") }}</span>
|
<span data-show="!$_locating">{{ icons::location("h-5 w-5") }}</span>
|
||||||
<span data-show="$_locating" style="display: none">{{ icons::spinner("h-5 w-5") }}</span>
|
<span data-show="$_locating" style="display: none"
|
||||||
|
>{{ icons::spinner("h-5 w-5") }}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
<input type="text" data-bind:_city-name
|
<input
|
||||||
|
type="text"
|
||||||
|
data-bind:_city-name
|
||||||
class="input-field w-full"
|
class="input-field w-full"
|
||||||
data-attr:placeholder="$_locationFound ? 'Using current location (' + $_userLat.toFixed(2) + ', ' + $_userLng.toFixed(2) + ')' : 'Name of city or area'"
|
data-attr:placeholder="$_locationFound ? 'Using current location (' + $_userLat.toFixed(2) + ', ' + $_userLng.toFixed(2) + ')' : 'Name of city or area'"
|
||||||
data-attr:disabled="$_locationFound"
|
data-attr:disabled="$_locationFound"
|
||||||
data-on:input__debounce.350ms="$_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
|
data-on:input__debounce.350ms="$_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" data-bind:_cafe-search
|
<input
|
||||||
|
type="text"
|
||||||
|
data-bind:_cafe-search
|
||||||
class="input-field w-full"
|
class="input-field w-full"
|
||||||
placeholder="Search for a cafe…"
|
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'}}) }"
|
data-on:input__debounce.350ms="if ($_locationFound) { @get('/api/v1/nearby-cafes?lat=' + $_userLat + '&lng=' + $_userLng + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) } else { $_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) }"
|
||||||
/>
|
/>
|
||||||
<div id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"></div>
|
<div
|
||||||
|
id="nearby-results"
|
||||||
|
class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,50 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
<nav class="border-b pb-4 text-sm">
|
<nav class="border-b pb-4 text-sm">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-semibold uppercase tracking-[0.25em] text-accent"><a href="/" class="-m-2 inline-block p-2">B{rew}log</a></div>
|
<div class="font-semibold uppercase tracking-[0.25em] text-accent">
|
||||||
|
<a href="/" class="-m-2 inline-block p-2">B{rew}log</a>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<a class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary" href="/add" title="Add" aria-label="Add">
|
<a
|
||||||
|
class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary"
|
||||||
|
href="/add"
|
||||||
|
title="Add"
|
||||||
|
aria-label="Add"
|
||||||
|
>
|
||||||
{{ icons::plus("h-5 w-5") }}
|
{{ icons::plus("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="button" class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary" title="Toggle theme" aria-label="Toggle theme" onclick="toggleTheme()">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary"
|
||||||
|
title="Toggle theme"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
onclick="toggleTheme()"
|
||||||
|
>
|
||||||
<span class="theme-icon-light">{{ icons::moon("h-5 w-5") }}</span>
|
<span class="theme-icon-light">{{ icons::moon("h-5 w-5") }}</span>
|
||||||
<span class="theme-icon-dark hidden">{{ icons::sun("h-5 w-5") }}</span>
|
<span class="theme-icon-dark hidden">{{ icons::sun("h-5 w-5") }}</span>
|
||||||
</button>
|
</button>
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<a class="rounded-md p-1.5 transition {% if nav_active == "admin" %}text-accent{% else %}text-text-muted hover:text-text-secondary{% endif %}" href="/admin" title="Admin" aria-label="Admin">
|
<a
|
||||||
|
class="rounded-md p-1.5 transition {% if nav_active == "admin" %}
|
||||||
|
text-accent
|
||||||
|
{% else %}
|
||||||
|
text-text-muted hover:text-text-secondary
|
||||||
|
{% endif %}"
|
||||||
|
href="/admin"
|
||||||
|
title="Admin"
|
||||||
|
aria-label="Admin"
|
||||||
|
>
|
||||||
{{ icons::user("h-5 w-5") }}
|
{{ icons::user("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary" href="/login" title="Login" aria-label="Login">
|
<a
|
||||||
|
class="rounded-md p-1.5 text-text-muted transition hover:text-text-secondary"
|
||||||
|
href="/login"
|
||||||
|
title="Login"
|
||||||
|
aria-label="Login"
|
||||||
|
>
|
||||||
{{ icons::user("h-5 w-5") }}
|
{{ icons::user("h-5 w-5") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -43,7 +70,8 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateThemeIcons = () => {
|
const updateThemeIcons = () => {
|
||||||
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
|
const isDark =
|
||||||
|
document.documentElement.getAttribute("data-theme") === "dark";
|
||||||
document.querySelectorAll(".theme-icon-light").forEach((el) => {
|
document.querySelectorAll(".theme-icon-light").forEach((el) => {
|
||||||
el.classList.toggle("hidden", isDark);
|
el.classList.toggle("hidden", isDark);
|
||||||
});
|
});
|
||||||
|
|
@ -52,7 +80,9 @@
|
||||||
});
|
});
|
||||||
const favicon = document.getElementById("favicon");
|
const favicon = document.getElementById("favicon");
|
||||||
if (favicon) {
|
if (favicon) {
|
||||||
favicon.href = isDark ? "/static/favicon-dark.svg" : "/static/favicon-light.svg";
|
favicon.href = isDark
|
||||||
|
? "/static/favicon-dark.svg"
|
||||||
|
: "/static/favicon-light.svg";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
<div id="nearby-results" class="mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface">
|
<div
|
||||||
|
id="nearby-results"
|
||||||
|
class="mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"
|
||||||
|
>
|
||||||
{% if cafes.is_empty() %}
|
{% if cafes.is_empty() %}
|
||||||
<p class="px-3 py-2 text-sm text-text-muted">No nearby cafes found.</p>
|
<p class="px-3 py-2 text-sm text-text-muted">No nearby cafes found.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h3 class="px-3 py-2 text-xs font-semibold text-text-muted uppercase tracking-wide">Nearby</h3>
|
<h3
|
||||||
|
class="px-3 py-2 text-xs font-semibold text-text-muted uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
Nearby
|
||||||
|
</h3>
|
||||||
{% for cafe in cafes %}
|
{% for cafe in cafes %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -16,7 +23,9 @@
|
||||||
data-on:click="$_cafeId = ''; $_cafeName = el.dataset.cafeName; $_cafeCity = el.dataset.cafeCity; $_cafeCountry = el.dataset.cafeCountry; $_cafeLat = parseFloat(el.dataset.cafeLat); $_cafeLng = parseFloat(el.dataset.cafeLng); $_cafeWebsite = el.dataset.cafeWebsite; $_reviewingCafe = true"
|
data-on:click="$_cafeId = ''; $_cafeName = el.dataset.cafeName; $_cafeCity = el.dataset.cafeCity; $_cafeCountry = el.dataset.cafeCountry; $_cafeLat = parseFloat(el.dataset.cafeLat); $_cafeLng = parseFloat(el.dataset.cafeLng); $_cafeWebsite = el.dataset.cafeWebsite; $_reviewingCafe = true"
|
||||||
>
|
>
|
||||||
<span class="font-medium text-text">{{ cafe.name }}</span>
|
<span class="font-medium text-text">{{ cafe.name }}</span>
|
||||||
<span class="ml-2 text-xs text-text-muted">{{ cafe.location }} · {{ cafe.distance }}</span>
|
<span class="ml-2 text-xs text-text-muted"
|
||||||
|
>{{ cafe.location }} · {{ cafe.distance }}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
{% macro scan_input(form_id, image_input_id, extracting_signal, error_signal, placeholder, spinner_text) %}
|
{% macro scan_input(form_id, image_input_id, extracting_signal, error_signal, placeholder, spinner_text) %}
|
||||||
<input type="hidden" name="image" id="{{ image_input_id }}" />
|
<input type="hidden" name="image" id="{{ image_input_id }}" />
|
||||||
<div data-show="!{{ extracting_signal }}" class="flex items-center gap-2">
|
<div data-show="!{{ extracting_signal }}" class="flex items-center gap-2">
|
||||||
<brew-photo-capture target-input="{{ image_input_id }}" target-form="{{ form_id }}"
|
<brew-photo-capture
|
||||||
|
target-input="{{ image_input_id }}"
|
||||||
|
target-form="{{ form_id }}"
|
||||||
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 text-accent transition hover:bg-surface-alt cursor-pointer"
|
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 text-accent transition hover:bg-surface-alt cursor-pointer"
|
||||||
aria-label="Take Photo"
|
aria-label="Take Photo"
|
||||||
>
|
>
|
||||||
|
|
@ -17,9 +19,18 @@
|
||||||
placeholder="{{ placeholder }}"
|
placeholder="{{ placeholder }}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div data-show="{{ extracting_signal }}" style="display:none" class="flex items-center gap-3 text-sm text-accent">
|
<div
|
||||||
|
data-show="{{ extracting_signal }}"
|
||||||
|
style="display:none"
|
||||||
|
class="flex items-center gap-3 text-sm text-accent"
|
||||||
|
>
|
||||||
{{ icons::spinner("h-5 w-5") }}
|
{{ icons::spinner("h-5 w-5") }}
|
||||||
{{ spinner_text }}
|
{{ spinner_text }}
|
||||||
</div>
|
</div>
|
||||||
<p data-show="{{ error_signal }}" data-text="{{ error_signal }}" style="display:none" class="mt-2 text-sm text-red-600"></p>
|
<p
|
||||||
|
data-show="{{ error_signal }}"
|
||||||
|
data-text="{{ error_signal }}"
|
||||||
|
style="display:none"
|
||||||
|
class="mt-2 text-sm text-red-600"
|
||||||
|
></p>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{% if geo_stats.entries.is_empty() %}
|
{% if geo_stats.entries.is_empty() %}
|
||||||
<div class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary">
|
<div
|
||||||
|
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
|
||||||
|
>
|
||||||
<p class="text-center">No data recorded yet.</p>
|
<p class="text-center">No data recorded yet.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -14,41 +16,63 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<chip-scroll class="relative block">
|
<chip-scroll class="relative block">
|
||||||
<button type="button" aria-label="Scroll left"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll left"
|
||||||
class="hidden absolute left-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute left-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-left
|
data-scroll-left
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_left("h-4 w-4") }}
|
{{ icons::chevron_left("h-4 w-4") }}
|
||||||
</button>
|
</button>
|
||||||
<div class="flex gap-2 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide" data-chip-scroll>
|
<div
|
||||||
|
class="flex gap-2 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide"
|
||||||
|
data-chip-scroll
|
||||||
|
>
|
||||||
{% for entry in geo_stats.entries %}
|
{% for entry in geo_stats.entries %}
|
||||||
{% if !entry.iso_code.is_empty() %}
|
{% if !entry.iso_code.is_empty() %}
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex shrink-0 snap-start items-center rounded-full border bg-surface text-sm transition cursor-pointer hover:border-accent/40"
|
class="inline-flex shrink-0 snap-start items-center rounded-full border bg-surface text-sm transition cursor-pointer hover:border-accent/40"
|
||||||
data-iso="{{ entry.iso_code|lower }}"
|
data-iso="{{ entry.iso_code|lower }}"
|
||||||
onclick="const map = document.querySelector('world-map'); const iso = this.dataset.iso; const cur = map.getAttribute('data-selected'); if (cur === iso) { map.removeAttribute('data-selected'); } else { map.setAttribute('data-selected', iso); } document.querySelectorAll('[data-iso]').forEach(b => b.classList.toggle('bg-accent-subtle', b.dataset.iso === map.getAttribute('data-selected')));">
|
onclick="const map = document.querySelector('world-map'); const iso = this.dataset.iso; const cur = map.getAttribute('data-selected'); if (cur === iso) { map.removeAttribute('data-selected'); } else { map.setAttribute('data-selected', iso); } document.querySelectorAll('[data-iso]').forEach(b => b.classList.toggle('bg-accent-subtle', b.dataset.iso === map.getAttribute('data-selected')));"
|
||||||
|
>
|
||||||
<span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2">
|
<span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2">
|
||||||
{% if !entry.flag_emoji.is_empty() %}
|
{% if !entry.flag_emoji.is_empty() %}
|
||||||
<span>{{ entry.flag_emoji }}</span>
|
<span>{{ entry.flag_emoji }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="font-semibold text-highlight whitespace-nowrap">{{ entry.country_name }}</span>
|
<span class="font-semibold text-highlight whitespace-nowrap"
|
||||||
|
>{{ entry.country_name }}</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
<span class="border-l py-1.5 pl-2 pr-3 font-semibold text-highlight">{{ entry.count }}</span>
|
<span
|
||||||
|
class="border-l py-1.5 pl-2 pr-3 font-semibold text-highlight"
|
||||||
|
>{{ entry.count }}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="inline-flex shrink-0 snap-start items-center rounded-full border bg-surface text-sm">
|
<div
|
||||||
|
class="inline-flex shrink-0 snap-start items-center rounded-full border bg-surface text-sm"
|
||||||
|
>
|
||||||
<span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2">
|
<span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2">
|
||||||
<span class="text-text whitespace-nowrap">{{ entry.country_name }}</span>
|
<span class="text-text whitespace-nowrap"
|
||||||
|
>{{ entry.country_name }}</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
<span class="border-l py-1.5 pl-2 pr-3 font-medium text-text">{{ entry.count }}</span>
|
<span class="border-l py-1.5 pl-2 pr-3 font-medium text-text"
|
||||||
|
>{{ entry.count }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="Scroll right"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll right"
|
||||||
class="hidden absolute right-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 text-text-muted shadow-sm transition hover:text-text"
|
class="hidden absolute right-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 text-text-muted shadow-sm transition hover:text-text"
|
||||||
data-scroll-right
|
data-scroll-right
|
||||||
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})">
|
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})"
|
||||||
|
>
|
||||||
{{ icons::chevron_right("h-4 w-4") }}
|
{{ icons::chevron_right("h-4 w-4") }}
|
||||||
</button>
|
</button>
|
||||||
</chip-scroll>
|
</chip-scroll>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
|
||||||
<div data-signals:{{ tab_signal }}="'{{ active_type }}'" data-signals:_tabs-open="false">
|
<div
|
||||||
|
data-signals:{{ tab_signal }}="'{{ active_type }}'"
|
||||||
|
data-signals:_tabs-open="false"
|
||||||
|
>
|
||||||
<!-- Desktop tabs -->
|
<!-- Desktop tabs -->
|
||||||
<nav class="hidden md:flex gap-1.5" role="tablist">
|
<nav class="hidden md:flex gap-1.5" role="tablist">
|
||||||
{% for tab in tabs %}
|
{% for tab in tabs %}
|
||||||
|
|
@ -9,8 +12,15 @@
|
||||||
role="tab"
|
role="tab"
|
||||||
class="tab"
|
class="tab"
|
||||||
data-class:tab-active="{{ tab_signal_js }} === '{{ tab.key }}'"
|
data-class:tab-active="{{ tab_signal_js }} === '{{ tab.key }}'"
|
||||||
data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'{% if !tab_base_url.is_empty() %}; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}'); @get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides: {selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}}){% endif %}"
|
data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'{% if !tab_base_url.is_empty() %}
|
||||||
>{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %} {{ tab.label }}</button>
|
; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}');
|
||||||
|
@get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides: {selector:
|
||||||
|
'{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
|
||||||
|
{% endif %}"
|
||||||
|
>
|
||||||
|
{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Mobile tab selector -->
|
<!-- Mobile tab selector -->
|
||||||
|
|
@ -22,20 +32,38 @@
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{% for tab in tabs %}
|
{% for tab in tabs %}
|
||||||
<span data-show="{{ tab_signal_js }} === '{{ tab.key }}'" class="inline-flex items-center gap-1.5">{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %} {{ tab.label }}</span>
|
<span
|
||||||
|
data-show="{{ tab_signal_js }} === '{{ tab.key }}'"
|
||||||
|
class="inline-flex items-center gap-1.5"
|
||||||
|
>{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
|
||||||
|
{{ tab.label }}</span
|
||||||
|
>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</span>
|
</span>
|
||||||
<span data-show="!$_tabsOpen">{{ icons::chevron_down("h-5 w-5") }}</span>
|
<span data-show="!$_tabsOpen">{{ icons::chevron_down("h-5 w-5") }}</span>
|
||||||
<span data-show="$_tabsOpen" style="display:none">{{ icons::chevron_up("h-5 w-5") }}</span>
|
<span data-show="$_tabsOpen" style="display:none"
|
||||||
|
>{{ icons::chevron_up("h-5 w-5") }}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
<div class="mt-1 flex flex-col rounded-md border overflow-hidden" data-show="$_tabsOpen" style="display:none">
|
<div
|
||||||
|
class="mt-1 flex flex-col rounded-md border overflow-hidden"
|
||||||
|
data-show="$_tabsOpen"
|
||||||
|
style="display:none"
|
||||||
|
>
|
||||||
{% for tab in tabs %}
|
{% for tab in tabs %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tab-mobile"
|
class="tab-mobile"
|
||||||
data-class:tab-mobile-active="{{ tab_signal_js }} === '{{ tab.key }}'"
|
data-class:tab-mobile-active="{{ tab_signal_js }} === '{{ tab.key }}'"
|
||||||
data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'; $_tabsOpen = false{% if !tab_base_url.is_empty() %}; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}'); @get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides: {selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}}){% endif %}"
|
data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'; $_tabsOpen = false{% if !tab_base_url.is_empty() %}
|
||||||
>{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %} {{ tab.label }}</button>
|
; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}');
|
||||||
|
@get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides:
|
||||||
|
{selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
|
||||||
|
{% endif %}"
|
||||||
|
>
|
||||||
|
{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
<h2 id="{{ month.anchor }}" class="timeline-heading scroll-mt-24 mb-6 text-2xl font-semibold text-text" data-timeline-month>
|
<h2
|
||||||
|
id="{{ month.anchor }}"
|
||||||
|
class="timeline-heading scroll-mt-24 mb-6 text-2xl font-semibold text-text"
|
||||||
|
data-timeline-month
|
||||||
|
>
|
||||||
{{ month.heading }}
|
{{ month.heading }}
|
||||||
<button type="button" class="timeline-top-btn" onclick="window.scrollTo({top: 0, behavior: 'smooth'})" aria-label="Back to top">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="timeline-top-btn"
|
||||||
|
onclick="window.scrollTo({top: 0, behavior: 'smooth'})"
|
||||||
|
aria-label="Back to top"
|
||||||
|
>
|
||||||
{{ icons::arrow_up("h-5 w-5") }}
|
{{ icons::arrow_up("h-5 w-5") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -9,8 +18,14 @@
|
||||||
class="block mx-auto mt-1 text-xs font-normal text-text-muted uppercase tracking-wide hover:text-accent transition"
|
class="block mx-auto mt-1 text-xs font-normal text-text-muted uppercase tracking-wide hover:text-accent transition"
|
||||||
data-on:click="if ($_expandedMonths.includes(',{{ month.anchor }}')) { $_expandedMonths = $_expandedMonths.replace(',{{ month.anchor }}', ''); $_expandedCard = ''; $_collapsedCards = '' } else { $_expandedMonths = $_expandedMonths + ',{{ month.anchor }}'; $_collapsedCards = '' }"
|
data-on:click="if ($_expandedMonths.includes(',{{ month.anchor }}')) { $_expandedMonths = $_expandedMonths.replace(',{{ month.anchor }}', ''); $_expandedCard = ''; $_collapsedCards = '' } else { $_expandedMonths = $_expandedMonths + ',{{ month.anchor }}'; $_collapsedCards = '' }"
|
||||||
>
|
>
|
||||||
<span data-show="!$_expandedMonths.includes(',{{ month.anchor }}')">Expand all</span>
|
<span data-show="!$_expandedMonths.includes(',{{ month.anchor }}')"
|
||||||
<span data-show="$_expandedMonths.includes(',{{ month.anchor }}')" style="display:none">Collapse all</span>
|
>Expand all</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-show="$_expandedMonths.includes(',{{ month.anchor }}')"
|
||||||
|
style="display:none"
|
||||||
|
>Collapse all</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
{% for event in month.events %}
|
{% for event in month.events %}
|
||||||
|
|
@ -30,25 +45,44 @@
|
||||||
{% if event.entity_type == "brew" %}{{ icons::beaker("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-3 w-3 shrink-0") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-3 w-3 shrink-0") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-3 w-3 shrink-0") }}{% endif %}
|
{% if event.entity_type == "brew" %}{{ icons::beaker("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-3 w-3 shrink-0") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-3 w-3 shrink-0") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-3 w-3 shrink-0") }}{% endif %}
|
||||||
<span class="uppercase tracking-wide">{{ event.kind_label }}</span>
|
<span class="uppercase tracking-wide">{{ event.kind_label }}</span>
|
||||||
{# Relative date — shown when collapsed #}
|
{# Relative date — shown when collapsed #}
|
||||||
<span class="tl-condensed uppercase tracking-wide" data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"> · {{ event.relative_date_label }}</span>
|
<span
|
||||||
|
class="tl-condensed uppercase tracking-wide"
|
||||||
|
data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"
|
||||||
|
>
|
||||||
|
· {{ event.relative_date_label }}</span
|
||||||
|
>
|
||||||
{# Full timestamp — shown when expanded #}
|
{# Full timestamp — shown when expanded #}
|
||||||
<time
|
<time
|
||||||
datetime="{{ event.iso_timestamp }}"
|
datetime="{{ event.iso_timestamp }}"
|
||||||
class="tl-detail uppercase tracking-wide"
|
class="tl-detail uppercase tracking-wide"
|
||||||
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
|
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
|
||||||
style="display:none"
|
style="display:none"
|
||||||
> · {{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{% endif %}</time>
|
>
|
||||||
|
·
|
||||||
|
{{ event.date_label }}{% if let Some(label) = event.time_label %}
|
||||||
|
· {{ label }}
|
||||||
|
{% endif %}</time
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
{# Expand/collapse chevron #}
|
{# Expand/collapse chevron #}
|
||||||
<span class="tl-chevron text-text-muted" data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')">
|
<span
|
||||||
|
class="tl-chevron text-text-muted"
|
||||||
|
data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"
|
||||||
|
>
|
||||||
{{ icons::chevron_down("h-4 w-4") }}
|
{{ icons::chevron_down("h-4 w-4") }}
|
||||||
</span>
|
</span>
|
||||||
<span class="tl-chevron text-text-muted" data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')" style="display:none">
|
<span
|
||||||
|
class="tl-chevron text-text-muted"
|
||||||
|
data-show="($_expandedCard.includes(',{{ event.id }}') || $_expandedMonths.includes(',{{ month.anchor }}')) && !$_collapsedCards.includes(',{{ event.id }}')"
|
||||||
|
style="display:none"
|
||||||
|
>
|
||||||
{{ icons::chevron_up("h-4 w-4") }}
|
{{ icons::chevron_up("h-4 w-4") }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-text">
|
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-text">
|
||||||
<a href="{{ event.link }}" class="hover:text-accent">{{ event.title }}</a>
|
<a href="{{ event.link }}" class="hover:text-accent"
|
||||||
|
>{{ event.title }}</a
|
||||||
|
>
|
||||||
{# External link — shown when expanded #}
|
{# External link — shown when expanded #}
|
||||||
{% if let Some(url) = event.external_link %}
|
{% if let Some(url) = event.external_link %}
|
||||||
<a
|
<a
|
||||||
|
|
@ -70,7 +104,9 @@
|
||||||
<p
|
<p
|
||||||
class="tl-condensed mt-1 text-sm text-text-muted"
|
class="tl-condensed mt-1 text-sm text-text-muted"
|
||||||
data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"
|
data-show="(!$_expandedCard.includes(',{{ event.id }}') && !$_expandedMonths.includes(',{{ month.anchor }}')) || $_collapsedCards.includes(',{{ event.id }}')"
|
||||||
>{{ sub }}</p>
|
>
|
||||||
|
{{ sub }}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Detail rows — shown when expanded #}
|
{# Detail rows — shown when expanded #}
|
||||||
{% if event.details.len() > 0 %}
|
{% if event.details.len() > 0 %}
|
||||||
|
|
@ -85,12 +121,26 @@
|
||||||
{% if detail.label.eq_ignore_ascii_case("position") && detail.link.is_some() %}
|
{% if detail.label.eq_ignore_ascii_case("position") && detail.link.is_some() %}
|
||||||
<dd class="text-right flex items-center justify-end gap-1.5">
|
<dd class="text-right flex items-center justify-end gap-1.5">
|
||||||
{{ detail.value }}
|
{{ detail.value }}
|
||||||
<a href="{{ detail.link.as_ref().unwrap() }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover" aria-label="View on map">
|
<a
|
||||||
|
href="{{ detail.link.as_ref().unwrap() }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
class="text-accent hover:text-accent-hover"
|
||||||
|
aria-label="View on map"
|
||||||
|
>
|
||||||
{{ icons::map("h-4 w-4") }}
|
{{ icons::map("h-4 w-4") }}
|
||||||
</a>
|
</a>
|
||||||
</dd>
|
</dd>
|
||||||
{% else if let Some(url) = detail.link %}
|
{% else if let Some(url) = detail.link %}
|
||||||
<dd class="text-right"><a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover">{{ detail.value }}</a></dd>
|
<dd class="text-right">
|
||||||
|
<a
|
||||||
|
href="{{ url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
class="text-accent hover:text-accent-hover"
|
||||||
|
>{{ detail.value }}</a
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
{% else %}
|
{% else %}
|
||||||
<dd class="text-right">{{ detail.value }}</dd>
|
<dd class="text-right">{{ detail.value }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -99,7 +149,9 @@
|
||||||
{% if let Some(notes) = event.tasting_notes %}
|
{% if let Some(notes) = event.tasting_notes %}
|
||||||
{% if !notes.is_empty() %}
|
{% if !notes.is_empty() %}
|
||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<dt class="shrink-0 font-medium text-text-muted">Tasting Notes</dt>
|
<dt class="shrink-0 font-medium text-text-muted">
|
||||||
|
Tasting Notes
|
||||||
|
</dt>
|
||||||
<dd class="flex flex-wrap gap-1 justify-end">
|
<dd class="flex flex-wrap gap-1 justify-end">
|
||||||
{% for note in notes %}
|
{% for note in notes %}
|
||||||
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue