8bd0a9b7ad
- Disable HTMX scrollIntoViewOnBoost, scroll to true y=0 on afterSettle so breadcrumbs are never hidden under the sticky header - Reorder recipe chips: Alcoholic → Category → Glass - Remove "Photographed / House pour" caption below recipe image Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
187 lines
7.1 KiB
JavaScript
187 lines
7.1 KiB
JavaScript
/* Bar Pivoine — main.js */
|
|
|
|
// ── Taxonomy slug helper (matches design's taxSlug) ──────────────────────────
|
|
function taxSlug(s) {
|
|
return String(s).toLowerCase().replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
}
|
|
|
|
// ── Build taxonomy facets from cocktail list ─────────────────────────────────
|
|
function buildTax(list) {
|
|
const acc = { alcoholic: {}, category: {}, glass: {}, ingredient: {} };
|
|
const add = (bucket, raw) => {
|
|
if (!raw) return;
|
|
const slug = taxSlug(raw);
|
|
if (!slug) return;
|
|
const e = bucket[slug] || (bucket[slug] = { value: raw, count: 0, slug });
|
|
e.count++;
|
|
};
|
|
list.forEach(c => {
|
|
add(acc.alcoholic, c.alcoholic);
|
|
add(acc.category, c.category);
|
|
add(acc.glass, c.glass);
|
|
(c.ingredients || []).forEach(i => add(acc.ingredient, i.name));
|
|
});
|
|
const sort = o => Object.values(o).sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
return {
|
|
alcoholic: sort(acc.alcoholic),
|
|
category: sort(acc.category),
|
|
glass: sort(acc.glass),
|
|
ingredient: sort(acc.ingredient),
|
|
};
|
|
}
|
|
|
|
// ── HTMX progress bar ────────────────────────────────────────────────────────
|
|
(function () {
|
|
const bar = document.getElementById("progress-bar");
|
|
if (!bar) return;
|
|
document.addEventListener("htmx:beforeRequest", () => bar.classList.add("htmx-request"));
|
|
document.addEventListener("htmx:afterSwap", () => { bar.classList.remove("htmx-request"); });
|
|
document.addEventListener("htmx:responseError", () => bar.classList.remove("htmx-request"));
|
|
})();
|
|
|
|
// ── Alpine store ─────────────────────────────────────────────────────────────
|
|
document.addEventListener("alpine:init", () => {
|
|
Alpine.store("nav", { open: false });
|
|
});
|
|
|
|
// ── Cocktail search + filter component (Alpine x-data) ───────────────────────
|
|
function cocktailSearch() {
|
|
return {
|
|
q: "",
|
|
active: { alcoholic: "", category: "", glass: "", ingredient: "" },
|
|
page: 1,
|
|
perPage: 24,
|
|
openFilter: null,
|
|
all: [],
|
|
tax: { alcoholic: [], category: [], glass: [], ingredient: [] },
|
|
|
|
init() {
|
|
this.all = window.__COCKTAILS__ || [];
|
|
this.tax = buildTax(this.all);
|
|
|
|
const p = new URLSearchParams(window.location.search);
|
|
this.q = p.get("q") || "";
|
|
this.active.alcoholic = p.get("alcoholic") || "";
|
|
this.active.category = p.get("category") || "";
|
|
this.active.glass = p.get("glass") || "";
|
|
this.active.ingredient = p.get("ingredient") || "";
|
|
this.page = parseInt(p.get("page") || "1", 10);
|
|
|
|
// Close any open filter on outside click
|
|
document.addEventListener("click", e => {
|
|
if (!e.target.closest("[data-fgroup]")) this.openFilter = null;
|
|
});
|
|
},
|
|
|
|
get filtered() {
|
|
const needle = this.q.trim().toLowerCase();
|
|
return this.all.filter(c => {
|
|
if (this.active.alcoholic && taxSlug(c.alcoholic) !== this.active.alcoholic) return false;
|
|
if (this.active.category && taxSlug(c.category) !== this.active.category) return false;
|
|
if (this.active.glass && taxSlug(c.glass) !== this.active.glass) return false;
|
|
if (this.active.ingredient && !c.ingredients.some(i => taxSlug(i.name) === this.active.ingredient)) return false;
|
|
if (needle) {
|
|
const hay = (c.name + " " + c.category + " " + c.glass + " " +
|
|
c.ingredients.map(i => i.name).join(" ")).toLowerCase();
|
|
if (!hay.includes(needle)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
},
|
|
|
|
get totalPages() {
|
|
return Math.max(1, Math.ceil(this.filtered.length / this.perPage));
|
|
},
|
|
|
|
get paged() {
|
|
const s = (this.page - 1) * this.perPage;
|
|
return this.filtered.slice(s, s + this.perPage);
|
|
},
|
|
|
|
get activeCount() {
|
|
return Object.values(this.active).filter(Boolean).length + (this.q ? 1 : 0);
|
|
},
|
|
|
|
get isFiltered() { return this.activeCount > 0; },
|
|
|
|
nobLabel(c) {
|
|
if (c.alcoholic === "Alcoholic") return "Spirited";
|
|
if (c.alcoholic === "Non alcoholic") return "Zero-proof";
|
|
return "Optional";
|
|
},
|
|
|
|
valLabel(key, slug) {
|
|
const t = this.tax[key].find(i => i.slug === slug);
|
|
return t ? t.value : slug;
|
|
},
|
|
|
|
toggleFilter(key) {
|
|
this.openFilter = this.openFilter === key ? null : key;
|
|
},
|
|
|
|
toggle(key, slug) {
|
|
this.active[key] = this.active[key] === slug ? "" : slug;
|
|
this.page = 1;
|
|
this.openFilter = null;
|
|
this.pushState();
|
|
},
|
|
|
|
setQuery(val) { this.q = val; this.page = 1; this.pushState(); },
|
|
|
|
clearAll() {
|
|
this.q = "";
|
|
this.active = { alcoholic: "", category: "", glass: "", ingredient: "" };
|
|
this.page = 1;
|
|
this.pushState();
|
|
},
|
|
|
|
changePage(n) {
|
|
this.page = Math.max(1, Math.min(this.totalPages, n));
|
|
this.pushState();
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
},
|
|
|
|
pagerItems() {
|
|
const total = this.totalPages, page = this.page;
|
|
const clamp = n => Math.max(1, Math.min(total, n));
|
|
const show = new Set([1, 2, clamp(page-2), clamp(page-1), page,
|
|
clamp(page+1), clamp(page+2), total-1, total]
|
|
.filter(n => n >= 1 && n <= total));
|
|
const sorted = [...show].sort((a, b) => a - b);
|
|
const items = [];
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
if (i > 0 && sorted[i] - sorted[i-1] > 1) items.push({ type: "dot" });
|
|
items.push({ type: "page", n: sorted[i] });
|
|
}
|
|
return items;
|
|
},
|
|
|
|
pushState() {
|
|
const p = new URLSearchParams();
|
|
if (this.q) p.set("q", this.q);
|
|
if (this.active.alcoholic) p.set("alcoholic", this.active.alcoholic);
|
|
if (this.active.category) p.set("category", this.active.category);
|
|
if (this.active.glass) p.set("glass", this.active.glass);
|
|
if (this.active.ingredient) p.set("ingredient", this.active.ingredient);
|
|
if (this.page > 1) p.set("page", this.page);
|
|
history.replaceState({}, "", p.toString() ? "?" + p : window.location.pathname);
|
|
},
|
|
};
|
|
}
|
|
|
|
window.cocktailSearch = cocktailSearch;
|
|
window.taxSlug = taxSlug;
|
|
|
|
// ── Page transition on HTMX swap ─────────────────────────────────────────────
|
|
document.addEventListener("htmx:afterSwap", () => {
|
|
const main = document.getElementById("main-content");
|
|
if (!main) return;
|
|
main.style.animation = "none";
|
|
void main.offsetHeight;
|
|
main.style.animation = "page-in 300ms ease-out";
|
|
});
|
|
|
|
document.addEventListener("htmx:afterSettle", () => {
|
|
window.scrollTo({ top: 0, behavior: "instant" });
|
|
});
|