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,262 @@
|
||||
{{ define "main" }}
|
||||
<div
|
||||
x-data="cocktailSearch()"
|
||||
x-init="init()"
|
||||
class="max-w-[1280px] mx-auto px-8 max-[860px]:px-5 pt-12 pb-[90px]"
|
||||
>
|
||||
|
||||
<!-- ══ SEARCH BAR ════════════════════════════════════════════════════════ -->
|
||||
<div class="mb-5">
|
||||
<div class="eyebrow">The cellar — {{ len .Pages }} recipes</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-[18px] pb-5 pt-1 cursor-text mb-0"
|
||||
@click="$refs.searchInput.focus()"
|
||||
>
|
||||
{{- partial "icon.html" "search" -}}
|
||||
<input
|
||||
x-ref="searchInput"
|
||||
type="text"
|
||||
:value="q"
|
||||
@input="setQuery($event.target.value)"
|
||||
placeholder="Name, spirit, ingredient, method…"
|
||||
class="arch-input flex-1 min-w-0 bg-transparent border-0 outline-none text-ink font-serif text-[clamp(26px,3.6vw,50px)] font-medium tracking-[-0.01em] leading-[1.15] py-[10px] caret-gold"
|
||||
aria-label="Search recipes"
|
||||
/>
|
||||
<button
|
||||
x-show="q"
|
||||
x-cloak
|
||||
@click.stop="setQuery('')"
|
||||
class="bg-transparent border-0 text-ink-mute text-[18px] px-[10px] py-1.5 cursor-pointer transition-colors duration-[150ms] leading-none flex-none hover:text-gold-2"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
{{- partial "icon.html" "x" -}}
|
||||
</button>
|
||||
<span class="font-mono text-[11.5px] tracking-[0.12em] uppercase text-gold whitespace-nowrap flex-none">
|
||||
<span x-text="isFiltered ? (filtered.length + ' / ' + all.length) : (all.length + ' recipes')"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-7 h-px bg-[linear-gradient(90deg,transparent_0%,rgba(233,210,180,0.18)_12%,rgba(233,210,180,0.18)_88%,transparent_100%)]"></div>
|
||||
|
||||
<!-- ══ FILTER BAR ════════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-[9px] items-center mb-7">
|
||||
|
||||
<!-- Spirit Style dropdown -->
|
||||
<div class="relative" data-fgroup>
|
||||
<button
|
||||
@click="toggleFilter('alcoholic')"
|
||||
:class="active.alcoholic ? 'border-gold text-gold-2' : (openFilter === 'alcoholic' ? 'border-gold' : 'border-warm/10 text-ink-soft')"
|
||||
class="inline-flex items-center gap-[9px] bg-surface border rounded-full px-[15px] py-[9px] transition-all duration-[180ms] hover:border-warm/18 hover:text-ink font-sans"
|
||||
type="button"
|
||||
>
|
||||
<span class="font-mono text-[11px] tracking-[0.06em] uppercase">Spirit Style</span>
|
||||
<span x-show="active.alcoholic" x-cloak class="font-sans text-[12.5px] text-gold-2" x-text="valLabel('alcoholic', active.alcoholic)"></span>
|
||||
<span class="text-[9px] opacity-70">{{- partial "icon.html" "chevron-down" -}}</span>
|
||||
</button>
|
||||
<div x-show="openFilter === 'alcoholic'" x-cloak class="fmenu">
|
||||
<template x-for="it in tax.alcoholic" :key="it.slug">
|
||||
<button
|
||||
@click="toggle('alcoholic', it.slug)"
|
||||
:class="active.alcoholic === it.slug ? 'text-gold-2 bg-gold/16' : 'text-ink-soft hover:bg-warm/[0.06] hover:text-ink'"
|
||||
class="flex justify-between items-center w-full text-left bg-transparent border-0 text-[14px] px-[11px] py-[9px] rounded-lg transition-all duration-[140ms] cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
<span x-text="it.value"></span>
|
||||
<span class="font-mono text-[11px] text-ink-faint" x-text="it.count"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category dropdown -->
|
||||
<div class="relative" data-fgroup>
|
||||
<button
|
||||
@click="toggleFilter('category')"
|
||||
:class="active.category ? 'border-gold text-gold-2' : (openFilter === 'category' ? 'border-gold' : 'border-warm/10 text-ink-soft')"
|
||||
class="inline-flex items-center gap-[9px] bg-surface border rounded-full px-[15px] py-[9px] transition-all duration-[180ms] hover:border-warm/18 hover:text-ink font-sans"
|
||||
type="button"
|
||||
>
|
||||
<span class="font-mono text-[11px] tracking-[0.06em] uppercase">Category</span>
|
||||
<span x-show="active.category" x-cloak class="font-sans text-[12.5px] text-gold-2" x-text="valLabel('category', active.category)"></span>
|
||||
<span class="text-[9px] opacity-70">{{- partial "icon.html" "chevron-down" -}}</span>
|
||||
</button>
|
||||
<div x-show="openFilter === 'category'" x-cloak class="fmenu">
|
||||
<template x-for="it in tax.category" :key="it.slug">
|
||||
<button
|
||||
@click="toggle('category', it.slug)"
|
||||
:class="active.category === it.slug ? 'text-gold-2 bg-gold/16' : 'text-ink-soft hover:bg-warm/[0.06] hover:text-ink'"
|
||||
class="flex justify-between items-center w-full text-left bg-transparent border-0 text-[14px] px-[11px] py-[9px] rounded-lg transition-all duration-[140ms] cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
<span x-text="it.value"></span>
|
||||
<span class="font-mono text-[11px] text-ink-faint" x-text="it.count"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glassware dropdown -->
|
||||
<div class="relative" data-fgroup>
|
||||
<button
|
||||
@click="toggleFilter('glass')"
|
||||
:class="active.glass ? 'border-gold text-gold-2' : (openFilter === 'glass' ? 'border-gold' : 'border-warm/10 text-ink-soft')"
|
||||
class="inline-flex items-center gap-[9px] bg-surface border rounded-full px-[15px] py-[9px] transition-all duration-[180ms] hover:border-warm/18 hover:text-ink font-sans"
|
||||
type="button"
|
||||
>
|
||||
<span class="font-mono text-[11px] tracking-[0.06em] uppercase">Glassware</span>
|
||||
<span x-show="active.glass" x-cloak class="font-sans text-[12.5px] text-gold-2" x-text="valLabel('glass', active.glass)"></span>
|
||||
<span class="text-[9px] opacity-70">{{- partial "icon.html" "chevron-down" -}}</span>
|
||||
</button>
|
||||
<div x-show="openFilter === 'glass'" x-cloak class="fmenu">
|
||||
<template x-for="it in tax.glass" :key="it.slug">
|
||||
<button
|
||||
@click="toggle('glass', it.slug)"
|
||||
:class="active.glass === it.slug ? 'text-gold-2 bg-gold/16' : 'text-ink-soft hover:bg-warm/[0.06] hover:text-ink'"
|
||||
class="flex justify-between items-center w-full text-left bg-transparent border-0 text-[14px] px-[11px] py-[9px] rounded-lg transition-all duration-[140ms] cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
<span x-text="it.value"></span>
|
||||
<span class="font-mono text-[11px] text-ink-faint" x-text="it.count"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active ingredient chip -->
|
||||
<template x-if="active.ingredient">
|
||||
<button
|
||||
@click="toggle('ingredient', active.ingredient)"
|
||||
class="chip on"
|
||||
type="button"
|
||||
>
|
||||
<span class="dot"></span>
|
||||
<span x-text="valLabel('ingredient', active.ingredient)"></span>
|
||||
<span> ✕</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Clear all -->
|
||||
<button
|
||||
x-show="activeCount > 0"
|
||||
x-cloak
|
||||
@click="clearAll()"
|
||||
class="bg-transparent border-0 font-mono text-[11px] tracking-[0.08em] uppercase text-ink-faint underline underline-offset-[3px] ml-1 cursor-pointer transition-colors duration-[160ms] hover:text-gold-2 px-0 py-0.5"
|
||||
type="button"
|
||||
>
|
||||
Clear (<span x-text="activeCount"></span>)
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ══ GRID ══════════════════════════════════════════════════════════════ -->
|
||||
<template x-if="paged.length > 0">
|
||||
<div>
|
||||
<div class="grid [grid-template-columns:repeat(auto-fill,minmax(258px,1fr))] gap-[22px] max-[560px]:[grid-template-columns:repeat(auto-fill,minmax(150px,1fr))] max-[560px]:gap-3.5">
|
||||
<template x-for="c in paged" :key="c.slug">
|
||||
<a
|
||||
:href="c.slug"
|
||||
class="block border border-warm/10 rounded-[14px] overflow-hidden bg-surface transition-[transform,border-color,box-shadow] duration-[260ms] ease-[cubic-bezier(.2,.7,.3,1)] relative hover:-translate-y-1 hover:border-warm/18 hover:shadow-[0_24px_50px_-24px_rgba(0,0,0,.8)] card-wrap"
|
||||
:aria-label="c.name"
|
||||
>
|
||||
<div class="aspect-square relative overflow-hidden">
|
||||
<img
|
||||
:src="c.photo || c.thumb || ''"
|
||||
:alt="c.name"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="card-img"
|
||||
onerror="this.style.background='#1c1611'"
|
||||
/>
|
||||
<span class="absolute top-[11px] left-[11px] z-[2] font-mono text-[9px] tracking-[0.12em] uppercase px-[9px] py-1 rounded-full bg-[rgba(13,10,7,0.6)] backdrop-blur-[6px] border border-warm/10 text-ink-soft" x-text="nobLabel(c)"></span>
|
||||
</div>
|
||||
<div class="px-[17px] pt-4 pb-[18px]">
|
||||
<div class="font-mono text-[10px] tracking-[0.16em] uppercase text-gold" x-text="c.category"></div>
|
||||
<h3 class="font-serif font-semibold text-[23px] leading-[1.05] tracking-tight mt-[7px] mb-[10px]" x-text="c.name"></h3>
|
||||
<div class="font-mono text-[10px] text-ink-mute tracking-[0.04em]" x-text="c.glass + (c.ingredients.length ? ' · ' + c.ingredients.length + ' ingredients' : '')"></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template x-if="totalPages > 1">
|
||||
<nav class="flex items-center justify-center gap-[6px] pt-14 flex-wrap" aria-label="pages">
|
||||
<button
|
||||
@click="changePage(page - 1)"
|
||||
:disabled="page === 1"
|
||||
class="inline-flex items-center gap-[7px] font-mono text-[12px] tracking-[0.1em] uppercase text-ink-soft bg-transparent border border-warm/10 rounded-full px-5 py-[11px] transition-all duration-[180ms] disabled:opacity-[0.28] disabled:cursor-default enabled:hover:border-gold enabled:hover:text-gold-2"
|
||||
type="button"
|
||||
>
|
||||
{{- partial "icon.html" "arrow-left" -}} Prev
|
||||
</button>
|
||||
<div class="flex items-center gap-[2px]">
|
||||
<template x-for="item in pagerItems()" :key="item.type + (item.n || '')">
|
||||
<span class="contents">
|
||||
<template x-if="item.type === 'dot'">
|
||||
<span class="text-ink-faint font-mono w-6 text-center text-[14px] leading-[38px]">…</span>
|
||||
</template>
|
||||
<template x-if="item.type === 'page'">
|
||||
<button
|
||||
@click="changePage(item.n)"
|
||||
:class="item.n === page ? 'bg-gold text-[#1a1206] font-semibold pointer-events-none' : 'bg-transparent text-ink-mute hover:text-ink hover:bg-warm/[0.07]'"
|
||||
class="font-mono text-[13px] w-[38px] h-[38px] rounded-full border-0 cursor-pointer transition-all duration-[150ms] flex items-center justify-center"
|
||||
x-text="item.n"
|
||||
type="button"
|
||||
></button>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
@click="changePage(page + 1)"
|
||||
:disabled="page === totalPages"
|
||||
class="inline-flex items-center gap-[7px] font-mono text-[12px] tracking-[0.1em] uppercase text-ink-soft bg-transparent border border-warm/10 rounded-full px-5 py-[11px] transition-all duration-[180ms] disabled:opacity-[0.28] disabled:cursor-default enabled:hover:border-gold enabled:hover:text-gold-2"
|
||||
type="button"
|
||||
>
|
||||
Next {{- partial "icon.html" "arrow-right" -}}
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="paged.length === 0">
|
||||
<div class="text-center py-[90px]">
|
||||
<div class="font-serif text-[54px] text-ink-faint leading-none">∅</div>
|
||||
<p class="font-serif text-[26px] text-ink-soft mt-2 mb-6">Nothing matches that pour.</p>
|
||||
<button @click="clearAll()" class="btn btn-ghost" type="button">Reset the search</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Cocktail data for Alpine search -->
|
||||
{{- $data := slice -}}
|
||||
{{- range .Pages -}}
|
||||
{{- $ings := .Params.ingredients | default (slice) -}}
|
||||
{{- $meas := .Params.ingredientMeasures | default (slice) -}}
|
||||
{{- $ingList := slice -}}
|
||||
{{- range $i, $ing := $ings -}}
|
||||
{{- $m := "" -}}{{- if lt $i (len $meas) -}}{{- $m = index $meas $i -}}{{- end -}}
|
||||
{{- $ingList = $ingList | append (dict "name" $ing "measure" $m) -}}
|
||||
{{- end -}}
|
||||
{{- $cat := "" -}}{{- with .Params.categories -}}{{- $cat = index . 0 -}}{{- end -}}
|
||||
{{- $glass := "" -}}{{- with .Params.glasses -}}{{- $glass = index . 0 -}}{{- end -}}
|
||||
{{- $photo := .Params.drinkThumbnail | default "" -}}
|
||||
{{- with .Resources.GetMatch "cocktail.*" -}}{{- $photo = (.Resize "500x webp").RelPermalink -}}{{- end -}}
|
||||
{{- $data = $data | append (dict
|
||||
"slug" .RelPermalink
|
||||
"name" .Title
|
||||
"alcoholic" (.Params.alcoholic | default "")
|
||||
"category" $cat
|
||||
"glass" $glass
|
||||
"ingredients" $ingList
|
||||
"photo" $photo
|
||||
) -}}
|
||||
{{- end -}}
|
||||
<script>
|
||||
window.__COCKTAILS__ = {{ $data | jsonify | safeJS }};
|
||||
</script>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,134 @@
|
||||
{{ define "main" }}
|
||||
{{- $img := .Resources.GetMatch "cocktail.*" -}}
|
||||
{{- $ingredients := .Params.ingredients | default (slice) -}}
|
||||
{{- $measures := .Params.ingredientMeasures | default (slice) -}}
|
||||
{{- $categories := .Params.categories | default (slice) -}}
|
||||
{{- $glasses := .Params.glasses | default (slice) -}}
|
||||
|
||||
{{/* Split instructions into numbered steps */}}
|
||||
{{- $steps := split .Content "\n" -}}
|
||||
|
||||
{{/* Related: same category via where, shuffle, exclude self */}}
|
||||
{{- $related := where .Site.RegularPages "Params.categories" "intersect" $categories -}}
|
||||
{{- $related = where $related "Title" "ne" .Title -}}
|
||||
{{- $related = first 4 (shuffle $related) -}}
|
||||
|
||||
<article class="max-w-[1280px] mx-auto px-8 max-[860px]:px-5 pb-10">
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<nav class="flex gap-[11px] items-center font-mono text-[11px] tracking-[0.1em] uppercase text-ink-faint pt-10 pb-7 flex-wrap">
|
||||
<a href="/" class="text-ink-mute hover:text-gold-2 transition-colors duration-[160ms]">Bar Pivoine</a>
|
||||
<span>/</span>
|
||||
<a href="/recipes/" class="text-ink-mute hover:text-gold-2 transition-colors duration-[160ms]">Cellar</a>
|
||||
<span>/</span>
|
||||
<span class="text-gold">{{ .Title }}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Detail grid -->
|
||||
<div class="grid grid-cols-[0.92fr_1.08fr] gap-14 items-start max-[900px]:grid-cols-1 max-[900px]:gap-8">
|
||||
|
||||
<!-- Left: sticky art frame -->
|
||||
<div class="sticky top-24 max-[900px]:static">
|
||||
<div class="relative aspect-square rounded-[18px] overflow-hidden border border-warm/18 shadow-[0_50px_90px_-45px_#000] frame-overlay">
|
||||
{{- if $img -}}
|
||||
{{- partial "img.html" (dict
|
||||
"res" $img
|
||||
"widths" (slice 800 1200)
|
||||
"sizes" "(max-width: 900px) 100vw, 45vw"
|
||||
"class" "w-full h-full object-cover"
|
||||
"alt" .Title
|
||||
"loading" "eager"
|
||||
) -}}
|
||||
{{- else -}}
|
||||
<img
|
||||
src="{{ .Params.drinkThumbnail }}"
|
||||
alt="{{ .Title }}"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{{- end -}}
|
||||
</div>
|
||||
<!-- Caption -->
|
||||
<div class="flex justify-between pt-[14px] px-1 font-mono text-[11px] tracking-[0.1em] uppercase text-ink-mute">
|
||||
<span>{{ range $glasses }}{{ . }}{{ end }}</span>
|
||||
<span class="text-gold">{{ if $img }}Photographed{{ else }}House pour{{ end }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: info column -->
|
||||
<div>
|
||||
<!-- Category eyebrow -->
|
||||
<div class="eyebrow">{{ range first 1 $categories }}{{ . }}{{ end }}</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="display text-[clamp(48px,6vw,84px)] mt-[14px]">{{ .Title }}</h1>
|
||||
|
||||
<!-- Chips -->
|
||||
<div class="flex flex-wrap gap-[9px] mt-[26px] mb-2">
|
||||
{{- with .Params.alcoholic -}}
|
||||
<span class="chip"><span class="dot"></span>{{ . }}</span>
|
||||
{{- end -}}
|
||||
{{- range $glasses -}}
|
||||
<a href="/glasses/{{ . | urlize }}/" class="chip"><span class="dot"></span>{{ . }}</a>
|
||||
{{- end -}}
|
||||
{{- range $categories -}}
|
||||
<a href="/categories/{{ . | urlize }}/" class="chip"><span class="dot"></span>{{ . }}</a>
|
||||
{{- end -}}
|
||||
</div>
|
||||
|
||||
<!-- Two columns: ingredients + method -->
|
||||
<div class="grid grid-cols-[1fr_1.15fr] gap-11 mt-10 max-[900px]:grid-cols-1 max-[900px]:gap-8">
|
||||
|
||||
<!-- Ingredients -->
|
||||
<div>
|
||||
<h4 class="font-mono text-[11px] tracking-[0.2em] uppercase text-gold mb-[18px] pb-3 border-b border-warm/10">Ingredients</h4>
|
||||
<ul class="list-none m-0 p-0">
|
||||
{{- range $i, $ingredient := $ingredients -}}
|
||||
<li class="flex justify-between items-baseline gap-3.5 py-3 border-b border-warm/[0.05]">
|
||||
<a
|
||||
href="/ingredients/{{ $ingredient | urlize }}/"
|
||||
class="font-serif text-[20px] text-ink transition-colors duration-[160ms] hover:text-gold-2"
|
||||
>{{ $ingredient }}</a>
|
||||
<span class="font-mono text-[12px] text-ink-mute text-right whitespace-nowrap tracking-[0.02em]">
|
||||
{{- if lt $i (len $measures) -}}{{ index $measures $i }}{{- else -}} {{- end -}}
|
||||
</span>
|
||||
</li>
|
||||
{{- end -}}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Method -->
|
||||
<div>
|
||||
<h4 class="font-mono text-[11px] tracking-[0.2em] uppercase text-gold mb-[18px] pb-3 border-b border-warm/10">Method</h4>
|
||||
<ol class="list-none m-0 p-0">
|
||||
{{ .Content }}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- ══ RELATED ════════════════════════════════════════════════════════════ -->
|
||||
{{- if gt (len $related) 0 -}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 max-[860px]:px-5 mt-[84px] pt-[10px]">
|
||||
|
||||
<div class="flex items-end justify-between gap-6 mb-[38px]">
|
||||
<div>
|
||||
<div class="eyebrow">From the same shelf</div>
|
||||
<h2 class="font-serif font-medium text-[clamp(34px,5vw,56px)] leading-none tracking-[-0.015em] mt-[10px] mb-0">You may also pour</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid [grid-template-columns:repeat(auto-fill,minmax(258px,1fr))] gap-[22px] max-[560px]:[grid-template-columns:repeat(auto-fill,minmax(150px,1fr))] max-[560px]:gap-3.5">
|
||||
{{- range $related -}}
|
||||
{{- partial "cocktail-card.html" . -}}
|
||||
{{- end -}}
|
||||
</div>
|
||||
</div>
|
||||
{{- end -}}
|
||||
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user