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>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/* 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";
|
||||
});
|
||||
Reference in New Issue
Block a user