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,239 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../../layouts/**/*.html";
|
||||
@source "../../content/**/*.md";
|
||||
@source "../../assets/js/**/*.js";
|
||||
|
||||
/* ── Design tokens ─────────────────────────────────────────────────────────── */
|
||||
@theme {
|
||||
/* Core palette — exact match to design */
|
||||
--color-bg: #14100c;
|
||||
--color-bg-deep: #0d0a07;
|
||||
--color-surface: #1c1611;
|
||||
--color-surface-2: #241c15;
|
||||
--color-warm: #e9d2b4; /* base for line/border via opacity modifiers */
|
||||
|
||||
/* Text scale */
|
||||
--color-ink: #efe6da;
|
||||
--color-ink-soft: #c9bbab;
|
||||
--color-ink-mute: #8d8073;
|
||||
--color-ink-faint: #5f574d;
|
||||
|
||||
/* Amber gold accent */
|
||||
--color-gold: #cf9648;
|
||||
--color-gold-2: #e3ad5e;
|
||||
--color-gold-deep: #9c6a2c;
|
||||
|
||||
/* Typography — overrides Tailwind defaults */
|
||||
--font-serif: "Cormorant Garamond", Georgia, serif;
|
||||
--font-sans: "Hanken Grotesk", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* ── Base ──────────────────────────────────────────────────────────────────── */
|
||||
@layer base {
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Warm ambient vignette */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(120% 80% at 50% -10%, rgba(207,150,72,0.07), transparent 60%),
|
||||
radial-gradient(100% 100% at 50% 120%, rgba(0,0,0,0.5), transparent 55%);
|
||||
}
|
||||
|
||||
#main-content { position: relative; z-index: 1; }
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
::selection { background: rgba(207,150,72,0.16); color: var(--color-gold-2); }
|
||||
|
||||
::-webkit-scrollbar { width: 11px; height: 11px; }
|
||||
::-webkit-scrollbar-track { background: var(--color-bg-deep); }
|
||||
::-webkit-scrollbar-thumb { background: #33291f; border-radius: 20px; border: 3px solid var(--color-bg-deep); }
|
||||
::-webkit-scrollbar-thumb:hover { background: #473829; }
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
}
|
||||
|
||||
/* ── Components — only things that cannot be expressed with utilities alone ── */
|
||||
@layer components {
|
||||
/* Eyebrow label — reused across many templates */
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Display type — large serif headlines */
|
||||
.display {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 500;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Chip — inline tag with hover states, used everywhere */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-soft);
|
||||
background: rgba(233,210,180,0.04);
|
||||
border: 1px solid rgba(233,210,180,0.10);
|
||||
padding: 6px 12px;
|
||||
border-radius: 30px;
|
||||
transition: 0.18s;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.chip:hover { border-color: var(--color-gold); color: var(--color-gold-2); }
|
||||
.chip.on { background: var(--color-gold); color: #1a1206; border-color: var(--color-gold); font-weight: 600; }
|
||||
.chip .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0.8; flex: none; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 13px 24px;
|
||||
border-radius: 30px;
|
||||
border: 1px solid transparent;
|
||||
transition: 0.22s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-gold { background: var(--color-gold); color: #1a1206; font-weight: 600; }
|
||||
.btn-gold:hover { background: var(--color-gold-2); transform: translateY(-1px); box-shadow: 0 10px 30px -10px var(--color-gold); }
|
||||
.btn-ghost { border-color: rgba(233,210,180,0.18); color: var(--color-ink); background: transparent; }
|
||||
.btn-ghost:hover { border-color: var(--color-gold); color: var(--color-gold-2); background: rgba(207,150,72,0.16); }
|
||||
|
||||
/* Cellar header button */
|
||||
.cellar-btn {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-gold);
|
||||
border: 1.5px solid rgba(207,150,72,0.55);
|
||||
border-radius: 30px;
|
||||
padding: 10px 20px;
|
||||
transition: background .2s, color .2s, border-color .2s, box-shadow .2s;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cellar-btn:hover {
|
||||
background: var(--color-gold);
|
||||
color: #1a1206;
|
||||
border-color: var(--color-gold);
|
||||
box-shadow: 0 6px 24px -8px rgba(207,150,72,0.45);
|
||||
}
|
||||
.cellar-btn.active {
|
||||
background: var(--color-gold);
|
||||
color: #1a1206;
|
||||
border-color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* Rise animation class */
|
||||
.rise { animation: rise .55s cubic-bezier(.2,.7,.3,1); }
|
||||
|
||||
/* Art Deco home separator lines — gradient cannot be expressed with utilities */
|
||||
/* Frame pseudo-overlay — bottom gradient on framed images */
|
||||
.frame-overlay::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(13,10,7,.5), transparent 42%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Card image hover scale — needs descendant selector */
|
||||
.card-img { width: 100%; height: 100%; object-fit: cover; transition: transform .5s; }
|
||||
.card-wrap:hover .card-img { transform: scale(1.05); }
|
||||
|
||||
/* Archive editorial search input placeholder */
|
||||
.arch-input::placeholder { color: var(--color-ink-faint); font-style: italic; }
|
||||
|
||||
/* Filter dropdown menu — absolutely positioned, can't be expressed with utilities */
|
||||
.fmenu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
min-width: 230px;
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid rgba(233,210,180,0.18);
|
||||
border-radius: 13px;
|
||||
padding: 7px;
|
||||
box-shadow: 0 30px 60px -28px #000;
|
||||
animation: rise .2s ease both;
|
||||
}
|
||||
|
||||
/* HTMX progress bar */
|
||||
#progress-bar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 999;
|
||||
height: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity .2s;
|
||||
background: linear-gradient(to right, var(--color-gold), var(--color-gold-2), var(--color-gold));
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
#progress-bar.htmx-request {
|
||||
opacity: 1;
|
||||
animation: progress-sweep 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Keyframes ─────────────────────────────────────────────────────────────── */
|
||||
@keyframes rise { from { transform: translateY(14px); } to { transform: none; } }
|
||||
@keyframes page-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes page-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-8px); } }
|
||||
@keyframes progress-sweep { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
|
||||
/* ── View Transitions ──────────────────────────────────────────────────────── */
|
||||
::view-transition-old(root) { animation: 200ms ease-in page-out; }
|
||||
::view-transition-new(root) { animation: 300ms ease-out page-in; }
|
||||
|
||||
/* ── Reduced motion ────────────────────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rise { animation: none; }
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) { animation: none; }
|
||||
}
|
||||
@@ -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