Files
bar/assets/js/main.js
T
valknar b3b9fb7ac6 Initial commit — Bar Pivoine cocktail recipe site
Hugo Extended site with 426 cocktail recipes from the open cocktail dataset.
Dark amber/gold editorial aesthetic, Tailwind CSS v4, Alpine.js client-side
search and filtering, HTMX page transitions, Docker + nginx production build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 11:53:45 +02:00

183 lines
7.0 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";
});