Archived
The site renders itself: Thymeleaf pages, and a catering page among them
build-and-publish / build (pull_request) Successful in 2m8s
build-and-publish / build (pull_request) Successful in 2m8s
The public site was a React SPA. It is now server-rendered Thymeleaf, and the goodie box and catering tables added in the previous commit have a page of their own. The look is unchanged: the templates carry the same Tailwind classes the components did, and every one of the 241 classes the five pages use resolves in the compiled stylesheet. WHAT WENT AWAY. PageMetaController — 148 lines whose only job was to splice per-page <title> and OG tags into one shell with regular expressions, with a test that read the real index.html so that reformatting it failed the build instead of silently breaking the rewriting. A page rendered on the server writes its own head. Also react-router (no client-side routes left), motion, vite-plugin-svgr, and the SPA fallback (platform.web.spa.enabled=false): with the site server-rendered, forwarding a mistyped URL to /index.html would answer with a blank admin shell and a 200 instead of the site's own 404 page. WHAT GOT BETTER ON THE WAY, none of it visible. The category filter is a ?category= link, so every filtered view is a URL you can send someone and a crawler can reach all forty items instead of the twelve the default filter showed. The contact form is a form post: the enquiry is recorded before delivery is attempted, and a refused relay re-renders the page with what the visitor typed still in the boxes. The mobile menu is a <details> — the React version needed four effects to close on navigation, close on Escape, stop the page behind it scrolling, and unmount (a panel parked off-screen still extends the scrollable area, which is how you used to be able to scroll sideways and find the menu); a new document cannot inherit an open menu. THE PUBLIC SITE SHIPS 5 KB OF JAVASCRIPT, and works without it. The product cards are scroll-snap strips, so the photos swipe on a phone and scroll with a trackpad unaided; gallery.js adds the arrows and the dots, and creates them itself rather than having the template render controls that would sit there dead. Tailwind still needs its compiler, so npm remains a BUILD tool: the CLI compiles the templates into static/css/site.css at process-classes (so `spring-boot:run` gets it too), and frontend/ now builds only that stylesheet and the admin. The brand tokens are one file both stylesheets import — the alternative was the shop front and the screen that edits it drifting a shade apart. The stylesheet URL carries ?v=<sha>, because one hand-written CSS file has no content hash and a deploy has to be able to tell a browser that what it cached is stale. The admin is still React and is untouched, apart from losing the router it no longer needs. It is an editor, not content. PlatformContractTest stopped inheriting platform-starter-test's contract and restates it. The shared version asserts that an unknown path forwards to the SPA shell, which is no longer true here, and its test methods are package-private so it cannot be overridden. The platform should decide that assertion from platform.web.spa.enabled — noted in the file. 9 new tests (46 total): every page's real title and og:url, the catalogue and the catering tables in the HTML rather than fetched afterwards, server-side filtering, the 404, and that a crafted ?about= link cannot put words of its own choosing in front of a customer. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -32,7 +32,11 @@ spring:
|
||||
platform:
|
||||
web:
|
||||
spa:
|
||||
enabled: true
|
||||
# OFF. The platform's fallback forwards every extension-less path to /index.html so a React SPA can
|
||||
# own routing; this site is server-rendered, and /index.html is now only the admin shell. Left on,
|
||||
# a mistyped URL would answer with a blank JavaScript page and a 200 instead of the site's own 404.
|
||||
# SiteController maps /admin to the shell explicitly — that one route is all the SPA is for.
|
||||
enabled: false
|
||||
data:
|
||||
auditing:
|
||||
enabled: true
|
||||
@@ -69,6 +73,10 @@ platform:
|
||||
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
|
||||
site:
|
||||
base-url: ${SITE_BASE_URL:https://itsthevine.com}
|
||||
# Hung on the stylesheet URL as ?v=… The SPA's bundles had content hashes in their filenames; one
|
||||
# hand-written stylesheet does not, so without this a returning visitor keeps the CSS they cached
|
||||
# before the deploy. The CI build already passes the commit sha to the image.
|
||||
build: ${GIT_SHA:dev}
|
||||
assets:
|
||||
# Where uploaded photos land. The key stored on a product is bucket-relative and excludes the
|
||||
# prefix, because ProductCatalog re-adds `/images/` when it builds the public URL.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 31 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Arrows and dots for the product cards that have more than one photo.
|
||||
*
|
||||
* This is the only JavaScript the public site loads, and the site works without it: each gallery is a
|
||||
* scroll-snap strip, so the photos are already swipeable on a phone and scrollable with a trackpad. What
|
||||
* this adds is the two chevrons and the row of dots the site has always had — controls that need script
|
||||
* to do anything, which is why they are created here rather than rendered in the template and left dead
|
||||
* for anyone whose JavaScript did not load.
|
||||
*
|
||||
* Tailwind finds the class names below because src/main/tailwind/site.css lists this directory as a
|
||||
* @source. Write them out in full — a class assembled from pieces at runtime would not be in the CSS.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var ARROW =
|
||||
'absolute top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 ' +
|
||||
'text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 ' +
|
||||
'focus-visible:ring-white';
|
||||
|
||||
function chevron(direction) {
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'h-5 w-5');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6');
|
||||
svg.appendChild(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function enhance(strip) {
|
||||
var photos = strip.querySelectorAll('img');
|
||||
if (photos.length < 2) return;
|
||||
|
||||
var frame = strip.parentElement;
|
||||
var name = photos[0].getAttribute('alt') || 'this item';
|
||||
|
||||
// Which photo is showing: whichever one's left edge is nearest the strip's scroll position. Read
|
||||
// from the scroll position rather than tracked in a variable, so a swipe and an arrow press can
|
||||
// never disagree about where we are.
|
||||
function current() {
|
||||
return Math.round(strip.scrollLeft / strip.clientWidth);
|
||||
}
|
||||
|
||||
function show(index) {
|
||||
var target = Math.max(0, Math.min(photos.length - 1, index));
|
||||
strip.scrollTo({ left: target * strip.clientWidth, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function arrow(direction, label, position) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = ARROW + ' ' + position;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.appendChild(chevron(direction));
|
||||
button.addEventListener('click', function () {
|
||||
// Wrapping, as the old carousel did: past the last photo you land back on the first.
|
||||
var next = current() + (direction === 'left' ? -1 : 1);
|
||||
if (next < 0) next = photos.length - 1;
|
||||
if (next > photos.length - 1) next = 0;
|
||||
show(next);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
frame.appendChild(arrow('left', 'Previous photo of ' + name, 'left-2'));
|
||||
frame.appendChild(arrow('right', 'Next photo of ' + name, 'right-2'));
|
||||
|
||||
var dots = document.createElement('div');
|
||||
dots.className = 'absolute inset-x-0 bottom-3 flex justify-center gap-1.5';
|
||||
var buttons = [];
|
||||
photos.forEach(function (_photo, index) {
|
||||
var dot = document.createElement('button');
|
||||
dot.type = 'button';
|
||||
dot.setAttribute(
|
||||
'aria-label',
|
||||
'Show photo ' + (index + 1) + ' of ' + photos.length + ' of ' + name,
|
||||
);
|
||||
dot.addEventListener('click', function () {
|
||||
show(index);
|
||||
});
|
||||
buttons.push(dot);
|
||||
dots.appendChild(dot);
|
||||
});
|
||||
frame.appendChild(dots);
|
||||
|
||||
function paint() {
|
||||
var active = current();
|
||||
buttons.forEach(function (dot, index) {
|
||||
dot.className =
|
||||
'h-1.5 rounded-full shadow-xs transition-all motion-reduce:transition-none focus:outline-none ' +
|
||||
'focus-visible:ring-2 focus-visible:ring-white ' +
|
||||
(index === active ? 'w-4 bg-white' : 'w-1.5 bg-white/60 hover:bg-white/80');
|
||||
if (index === active) {
|
||||
dot.setAttribute('aria-current', 'true');
|
||||
} else {
|
||||
dot.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard: the strip is focusable, so left/right work once it has focus.
|
||||
strip.tabIndex = 0;
|
||||
strip.setAttribute('role', 'group');
|
||||
strip.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
show(current() - 1);
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
show(current() + 1);
|
||||
}
|
||||
});
|
||||
|
||||
var scheduled = false;
|
||||
strip.addEventListener(
|
||||
'scroll',
|
||||
function () {
|
||||
// A smooth scroll fires this dozens of times; repaint once per frame.
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(function () {
|
||||
scheduled = false;
|
||||
paint();
|
||||
});
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
paint();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.gallery').forEach(enhance);
|
||||
})();
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="min-h-screen bg-bakery-50">
|
||||
|
||||
<header class="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900">Goodie boxes & catering</h1>
|
||||
<p class="mt-4 text-bakery-800 max-w-2xl mx-auto leading-relaxed">
|
||||
Boxes for the office, packages for a party, and cakes and desserts for a wedding. Every one of
|
||||
these is a starting point — tell us what you have in mind and we will work from it.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16 md:pb-24">
|
||||
<div class="max-w-4xl mx-auto space-y-10 md:space-y-12">
|
||||
|
||||
<!--/*
|
||||
One table per package. Two renderings of the same data, and only one is ever visible:
|
||||
|
||||
- A real <table> from `md` up. These are prices in columns; a table is what that is, and a
|
||||
screen reader announces the size and the item together because of the row and column headers.
|
||||
- Stacked cards below `md`. A four-column price table on a phone is either an illegible squeeze
|
||||
or a sideways scroll, and this page is mostly read on phones.
|
||||
|
||||
Both come from the same model, so they cannot drift.
|
||||
*/-->
|
||||
<section th:each="table : ${menu.packages}"
|
||||
class="bg-white rounded-3xl shadow-xs overflow-hidden">
|
||||
<div class="px-6 pt-6 md:px-8 md:pt-8">
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900" th:text="${table.name}">Office</h2>
|
||||
<p th:if="${table.blurb}" class="mt-2 text-bakery-700" th:text="${table.blurb}">Blurb</p>
|
||||
</div>
|
||||
|
||||
<!--/* Wide: the table. */-->
|
||||
<div class="hidden md:block px-8 pt-6">
|
||||
<table class="w-full border-collapse text-left">
|
||||
<caption class="sr-only" th:text="|${table.name} — what each size includes and what it costs|">Sizes</caption>
|
||||
<thead>
|
||||
<tr class="border-b border-bakery-200">
|
||||
<th scope="col" class="py-3 pr-4 text-xs uppercase tracking-[0.15em] text-bakery-600 font-medium align-bottom">
|
||||
What you get
|
||||
</th>
|
||||
<th th:each="tier : ${table.tiers}" scope="col" class="py-3 px-4 align-bottom">
|
||||
<span class="block font-adbhashitha text-lg text-bakery-900" th:text="${tier.label}">Small</span>
|
||||
<span th:if="${tier.price}" class="block text-bakery-700" th:text="${tier.price}">$24</span>
|
||||
<!--/* No price means "ask us" — say so rather than leaving a hole in the column. */-->
|
||||
<span th:unless="${tier.price}" class="block text-bakery-600 text-sm">Ask us</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="row : ${table.rows}" class="border-b border-bakery-100 last:border-0 align-top">
|
||||
<th scope="row" class="py-4 pr-4 font-medium text-bakery-900" th:text="${row.label}">Mini muffins</th>
|
||||
<td th:each="value : ${row.values}" class="py-4 px-4 text-bakery-800">
|
||||
<span th:if="${!#strings.isEmpty(value)}" th:text="${value}">12 items</span>
|
||||
<!--/* A cell the bakery hasn't filled in. An em dash reads as "nothing here"; an empty
|
||||
cell reads as a broken page. */-->
|
||||
<span th:if="${#strings.isEmpty(value)}" class="text-bakery-400" aria-hidden="true">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--/* Narrow: one card per size. */-->
|
||||
<div class="md:hidden px-6 pt-6 space-y-4">
|
||||
<div th:each="tier, t : ${table.tiers}" class="rounded-2xl border border-bakery-200 p-4">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h3 class="font-adbhashitha text-lg text-bakery-900" th:text="${tier.label}">Small</h3>
|
||||
<span th:if="${tier.price}" class="text-bakery-700 whitespace-nowrap" th:text="${tier.price}">$24</span>
|
||||
<span th:unless="${tier.price}" class="text-bakery-600 text-sm whitespace-nowrap">Ask us</span>
|
||||
</div>
|
||||
<!--/* The cell for this size on each line — t.index picks this column out of every row, which
|
||||
is exactly the alignment the aggregate guarantees. Lines with nothing in this column are
|
||||
still listed: they are part of what's in the box, and the bakery just hasn't said how
|
||||
many yet. */-->
|
||||
<dl class="mt-3 space-y-2">
|
||||
<div th:each="row : ${table.rows}" class="flex justify-between gap-4 text-sm">
|
||||
<dt class="text-bakery-900" th:text="${row.label}">Mini muffins</dt>
|
||||
<dd class="text-bakery-800 text-right">
|
||||
<span th:if="${!#strings.isEmpty(row.values[t.index])}" th:text="${row.values[t.index]}">12 items</span>
|
||||
<span th:if="${#strings.isEmpty(row.values[t.index])}" class="text-bakery-400">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:if="${!#lists.isEmpty(table.notes)}" class="px-6 md:px-8 pt-6">
|
||||
<ul class="space-y-2 text-sm text-bakery-700">
|
||||
<li th:each="note : ${table.notes}" th:text="${note}">Minimum of 6 items per baked good.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pb-6 pt-6 md:px-8 md:pb-8">
|
||||
<a th:href="|/contact?about=${#uris.escapeQueryParam(table.name)}|"
|
||||
class="inline-block bg-bakery-600 hover:bg-bakery-700 text-white px-6 py-3 rounded-full font-medium tracking-wide transition">
|
||||
Ask about <span th:text="${#strings.toLowerCase(table.name)}">office</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--/* The terms that apply whichever table you were reading. */-->
|
||||
<section th:if="${!#lists.isEmpty(menu.notes)}" class="rounded-3xl border border-bakery-200 p-6 md:p-8">
|
||||
<h2 class="font-adbhashitha text-xl text-bakery-900">Before you order</h2>
|
||||
<ul class="mt-4 space-y-2 text-bakery-800">
|
||||
<li th:each="note : ${menu.notes}" th:text="${note}">Prices may change.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!--/* Only shows if every table has been emptied out in the admin. Better than a bare heading. */-->
|
||||
<p th:if="${#lists.isEmpty(menu.packages)}" class="text-center text-bakery-800">
|
||||
Our catering list is being updated. Call us on
|
||||
<a href="tel:+13097010660" class="underline underline-offset-4">(309) 701-0660</a> and we will
|
||||
talk it through.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="min-h-screen bg-bakery-50">
|
||||
|
||||
<header class="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900">Contact us</h1>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16 md:pb-24">
|
||||
<div class="max-w-2xl mx-auto bg-white p-8 md:p-10 rounded-3xl shadow-xs">
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-2">Get in touch</h2>
|
||||
<p class="text-bakery-700 mb-8">
|
||||
Or call us: <a href="tel:+13097010660" class="underline underline-offset-4 hover:text-bakery-600">(309) 701-0660</a>
|
||||
</p>
|
||||
|
||||
<!--/*
|
||||
A plain form post. The enquiry is validated, written down and mailed by the server, which then
|
||||
renders this page again with the outcome — no fetch, no JSON, and nothing to go wrong between
|
||||
pressing the button and the enquiry being recorded.
|
||||
|
||||
Spring Security's CSRF token is added to any th:action form automatically, which is what keeps
|
||||
this working on a deployment that has an identity provider configured (there, an unaccompanied
|
||||
POST is rejected).
|
||||
*/-->
|
||||
<!--/* th:unless, not th:if="${!sent}": the attribute is absent on a GET, and SpEL cannot negate
|
||||
a null. */-->
|
||||
<form th:action="@{/contact}" method="post" th:unless="${sent}">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-bakery-800 mb-2" for="name">Name</label>
|
||||
<input type="text" id="name" name="name" th:value="${name}" required
|
||||
class="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-bakery-800 mb-2" for="email">Email</label>
|
||||
<input type="email" id="email" name="email" th:value="${email}" required
|
||||
class="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-bakery-800 mb-2" for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="5" required th:text="${message}"
|
||||
class="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"></textarea>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-8 py-3 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors">
|
||||
Send message
|
||||
</button>
|
||||
|
||||
<!--/* Never claim a success we did not get: say what happened and give them the phone number. */-->
|
||||
<p th:if="${error}" role="alert" class="text-center text-red-700">
|
||||
<span th:text="${error}">Could not send the message.</span>
|
||||
Please call us on
|
||||
<a href="tel:+13097010660" class="underline underline-offset-4">(309) 701-0660</a>.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p th:if="${sent}" role="status" class="text-bakery-700">
|
||||
Thanks. Your message is on its way, and we will get back to you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<!--/*
|
||||
Anything that isn't a 404: the site's own page rather than Boot's white error screen, which shows a
|
||||
stack-trace-shaped block of text to whoever happens to be standing in the shop.
|
||||
|
||||
It deliberately says nothing about what broke. The person reading it can't act on that; the phone
|
||||
number is the thing they can use, and the log has the details.
|
||||
*/-->
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/page :: page(
|
||||
'Something went wrong · The Vine Coffeehouse + Bakery',
|
||||
'Something went wrong on our side. Call The Vine on (309) 701-0660.',
|
||||
'/', ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="bg-bakery-50">
|
||||
<div class="container mx-auto px-4 py-24 md:py-32 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900 mb-6">Something went wrong</h1>
|
||||
<p class="text-bakery-800 mb-10">
|
||||
That is our fault, not yours. Try again in a moment, or call us on
|
||||
<a href="tel:+13097010660" class="underline underline-offset-4">(309) 701-0660</a>.
|
||||
</p>
|
||||
<a href="/"
|
||||
class="bg-bakery-600 hover:bg-bakery-700 text-white px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition">
|
||||
Back home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<!--/*
|
||||
The site's own 404, in the site's own chrome.
|
||||
|
||||
Boot picks this up by name for any 404, which is why the SPA fallback is switched off in
|
||||
application.yaml: while it was on, every mistyped URL was answered with the JavaScript shell and a
|
||||
200 — and now that the shell only carries the admin, that would be a blank page.
|
||||
|
||||
Boot renders it through its own error controller, so SiteController's model isn't here — which is why
|
||||
the layout takes the title and description as parameters and this page passes its own.
|
||||
*/-->
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/page :: page(
|
||||
'Page not found · The Vine Coffeehouse + Bakery',
|
||||
'That page is not here. The menu, our catering list, our story and how to reach us are.',
|
||||
'/', ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="bg-bakery-50">
|
||||
<div class="container mx-auto px-4 py-24 md:py-32 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900 mb-6">
|
||||
We could not find that page
|
||||
</h1>
|
||||
<p class="text-bakery-800 mb-10">
|
||||
It may have moved. The menu, our story, and how to reach us are all still here.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<a href="/"
|
||||
class="bg-bakery-600 hover:bg-bakery-700 text-white px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition">
|
||||
Back home
|
||||
</a>
|
||||
<a href="/products"
|
||||
class="border border-bakery-300 hover:bg-bakery-100 text-bakery-700 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition">
|
||||
See the menu
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<footer th:fragment="footer" class="bg-bakery-900 text-bakery-100">
|
||||
<div class="container mx-auto px-4 py-14">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-10">
|
||||
<div class="col-span-1 md:col-span-2 flex items-start">
|
||||
<div th:replace="~{fragments/lockup :: small('text-bakery-50')}"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Navigation</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="/products" class="hover:text-white transition">Our Products</a></li>
|
||||
<li><a href="/catering" class="hover:text-white transition">Goodie Boxes & Catering</a></li>
|
||||
<li><a href="/history" class="hover:text-white transition">Our Story</a></li>
|
||||
<li><a href="/contact" class="hover:text-white transition">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Visit</h3>
|
||||
<address class="not-italic space-y-2">
|
||||
<p>215 E Main Street<br>Princeville, IL 61559</p>
|
||||
<p><a href="tel:+13097010660" class="hover:text-white transition">(309) 701-0660</a></p>
|
||||
<p class="break-words">
|
||||
<a href="mailto:[email protected]" class="hover:text-white transition">[email protected]</a>
|
||||
</p>
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center text-sm text-bakery-300">
|
||||
<p>© <span th:text="${year}">2026</span> The Vine Coffeehouse + Bakery</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!--/*
|
||||
The page head. `title`, `description`, `path` and (on the homepage) `preload` come from the model —
|
||||
see SiteController, where each route states its own.
|
||||
|
||||
This is what the SPA could not do. PageMetaController used to rewrite the shell's <title> and four
|
||||
meta tags with regular expressions on the way out, and its test read the real index.html so that
|
||||
reformatting that file failed the build rather than silently breaking the rewriting. A page that is
|
||||
rendered on the server just writes its own head, so all of that is gone.
|
||||
*/-->
|
||||
<head th:fragment="head(title, description, path)">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link rel="icon" media="(prefers-color-scheme: light)" href="/images/resources/logo_L.png">
|
||||
<link rel="icon" media="(prefers-color-scheme: dark)" href="/images/resources/logo_dark.png">
|
||||
|
||||
<!--/* Open the connection to the photo bucket while the head is still parsing. */-->
|
||||
<link rel="preconnect" th:href="${assetOrigin}" crossorigin>
|
||||
<!--/* The wordmark is set in Raleway; without preloading it arrives late and the logo visibly reflows. */-->
|
||||
<link rel="preload" as="font" type="font/woff2" href="/fonts/raleway-latin.woff2" crossorigin>
|
||||
<link th:if="${preload}" rel="preload" as="image" th:href="${preload}" fetchpriority="high">
|
||||
|
||||
<title th:text="${title}">The Vine Coffeehouse + Bakery</title>
|
||||
<meta name="description" th:content="${description}">
|
||||
<meta property="og:title" th:content="${title}">
|
||||
<meta property="og:description" th:content="${description}">
|
||||
<meta property="og:url" th:content="${baseUrl + (path == '/' ? '' : path)}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta name="keywords" content="bakery, coffeehouse, pastries, custom cakes, cinnamon rolls, paninis, Princeville IL">
|
||||
|
||||
<!--/* ?v= is the commit sha in a deployment. One hand-written stylesheet has no content hash in its
|
||||
name, so without it a returning visitor keeps the CSS they cached before the deploy. */-->
|
||||
<link rel="stylesheet" th:href="|/css/site.css?v=${build}|">
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<!--/*
|
||||
The sticky bar, and the mobile menu.
|
||||
|
||||
The menu is a <details> element. That is not a trick to avoid writing JavaScript for its own sake —
|
||||
the React version needed an effect to close the panel on navigation, another to close it on Escape,
|
||||
another to stop the page behind it scrolling, and a third to unmount it (a panel parked off-screen
|
||||
still extends the scrollable area, which is how you used to be able to scroll sideways and find the
|
||||
menu). A <details> closes on Escape by itself, and navigation is a new document, so it cannot survive
|
||||
it. Nothing to remember, nothing to clean up.
|
||||
|
||||
The panel is `absolute` and starts below the bar (`top-full`), so the logo and the toggle stay put and
|
||||
the panel needs no second copy of either. It must not be `fixed`: the header carries `backdrop-blur`,
|
||||
and a backdrop-filter makes an element a containing block for fixed descendants — a fixed panel in
|
||||
here would resolve against the 80px header box and get clipped to a sliver.
|
||||
*/-->
|
||||
<header th:fragment="header" class="sticky top-0 z-40 bg-bakery-50/90 backdrop-blur border-b border-bakery-200">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between gap-2 h-20 md:h-24">
|
||||
<div th:replace="~{fragments/lockup :: small('text-bakery-700')}"></div>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-8">
|
||||
<th:block th:replace="~{:: links('text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition')}"></th:block>
|
||||
</nav>
|
||||
|
||||
<details class="group md:hidden shrink-0">
|
||||
<summary class="p-2 cursor-pointer list-none" aria-label="Menu">
|
||||
<svg class="h-6 w-6 text-bakery-700 group-open:hidden" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg class="h-6 w-6 text-bakery-700 hidden group-open:block" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="absolute inset-x-0 top-full z-30 h-[calc(100dvh-5rem)] bg-bakery-50">
|
||||
<nav class="container mx-auto px-4 flex flex-col">
|
||||
<th:block th:replace="~{:: links('text-bakery-800 hover:text-bakery-600 transition py-4 text-lg border-b border-bakery-100')}"></th:block>
|
||||
</nav>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!--/* One list of links, worn two ways — so the phone and the desktop can't end up offering different
|
||||
pages. */-->
|
||||
<th:block th:fragment="links(itemClass)">
|
||||
<a href="/products" th:class="${itemClass}">Our Products</a>
|
||||
<a href="/catering" th:class="${itemClass}">Catering</a>
|
||||
<a href="/history" th:class="${itemClass}">Our Story</a>
|
||||
<a href="/contact" th:class="${itemClass}">Contact</a>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<!--/*
|
||||
The wordmark: branch · "The Vine" over "Coffeehouse + Bakery" · branch.
|
||||
|
||||
The two branch marks are masks over currentColor (see .mark in tokens.css), so the whole lockup takes
|
||||
its colour from the `tint` class the caller passes — sage in the header, cream on the hero and in the
|
||||
footer. Branch heights track the two-line wordmark so the marks read as part of it.
|
||||
*/-->
|
||||
|
||||
<th:block th:fragment="inner(branch, name, tag)">
|
||||
<span th:class="'mark mark-r shrink-0 ' + ${branch}" aria-hidden="true"></span>
|
||||
<span class="flex flex-col items-center leading-none min-w-0">
|
||||
<span th:class="'font-lejour ' + ${name}" style="letter-spacing: 0.01em">The Vine</span>
|
||||
<span th:class="'font-adbhashitha uppercase mt-1.5 whitespace-nowrap ' + ${tag}">Coffeehouse + Bakery</span>
|
||||
</span>
|
||||
<span th:class="'mark mark-l shrink-0 ' + ${branch}" aria-hidden="true"></span>
|
||||
</th:block>
|
||||
|
||||
<!--/* Header and footer: a link home. */-->
|
||||
<a th:fragment="small(tint)" href="/"
|
||||
th:class="'flex items-center gap-1.5 sm:gap-2 min-w-0 shrink ' + ${tint}"
|
||||
aria-label="The Vine Coffeehouse + Bakery, home">
|
||||
<th:block th:replace="~{:: inner('w-12 h-12 sm:w-14 sm:h-14', 'text-xl sm:text-2xl md:text-3xl', 'text-[0.6rem] sm:text-xs tracking-[0.18em]')}"></th:block>
|
||||
</a>
|
||||
|
||||
<!--/* The hero, on the homepage — where a link back to "/" would be pointless. */-->
|
||||
<div th:fragment="large(tint)"
|
||||
th:class="'flex items-center justify-center gap-2 sm:gap-4 min-w-0 shrink ' + ${tint}"
|
||||
role="img" aria-label="The Vine Coffeehouse + Bakery">
|
||||
<th:block th:replace="~{:: inner('w-20 h-20 sm:w-28 sm:h-28', 'text-4xl sm:text-5xl md:text-6xl', 'text-xs sm:text-base tracking-[0.2em]')}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<!--/*
|
||||
The shell every page wears: the head, the sticky header, the footer, and the flex column that keeps
|
||||
the footer at the bottom of a short page.
|
||||
|
||||
A page replaces its own <html> with this fragment and passes its content in as `~{::content}` — a
|
||||
reference to the fragment named "content" in the page's own file. That is how a layout works in
|
||||
Thymeleaf without a layout dialect, and it means the chrome exists once: a page that wanted its own
|
||||
header would have to say so.
|
||||
|
||||
Title, description and path are parameters rather than model lookups so that the error pages — which
|
||||
Boot renders through its own controller, with none of SiteController's model — can pass their own.
|
||||
*/-->
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:fragment="page(title, description, path, content)">
|
||||
<head th:replace="~{fragments/head :: head(${title}, ${description}, ${path})}"></head>
|
||||
<body>
|
||||
<div class="min-h-screen bg-bakery-50 flex flex-col">
|
||||
<div th:replace="~{fragments/header :: header}"></div>
|
||||
<main class="flex-grow">
|
||||
<div th:replace="${content}"></div>
|
||||
</main>
|
||||
<div th:replace="~{fragments/footer :: footer}"></div>
|
||||
</div>
|
||||
<!--/* The only script on the public site: it gives the multi-photo product cards their arrows and
|
||||
dots. Without it they are still a swipeable strip of photos. */-->
|
||||
<script defer th:src="|/js/gallery.js?v=${build}|"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="bg-bakery-50">
|
||||
|
||||
<header class="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900">Our story</h1>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16 md:pb-24">
|
||||
<div class="max-w-2xl mx-auto space-y-12">
|
||||
<section>
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">How it started</h2>
|
||||
<p class="text-bakery-800 leading-relaxed">
|
||||
Morissa Bennett opened The Vine in 2024, at 215 E Main Street, in the middle of downtown
|
||||
Princeville. The plan was not complicated. Bake it ourselves, sell it ourselves, and keep
|
||||
enough tables that nobody feels rushed out the door.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">What we make</h2>
|
||||
<p class="text-bakery-800 leading-relaxed">
|
||||
We opened with coffee and pastries. The menu kept growing. Now there are cinnamon rolls
|
||||
and caramel rolls, scones, cookie bars, macarons, brownies, and pies, plus sandwiches and
|
||||
paninis once the lunch crowd shows up.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">The cakes are the fun part</h2>
|
||||
<p class="text-bakery-800 leading-relaxed">
|
||||
Cakes and decorated cookies are made to order, which means we mostly bake whatever
|
||||
Princeville is celebrating that week. We have done a tractor, a cow, a 76th birthday, a
|
||||
retirement, a wedding, and a cake for the class of 1964. We have iced sugar cookies for
|
||||
the cross country team and for a bridal party. If you can describe it, we will have a go
|
||||
at it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">Around town</h2>
|
||||
<p class="text-bakery-800 leading-relaxed">
|
||||
We turn out for Christmas in the Village every year and for other civic events, and the
|
||||
Princeville Civic Association counts us among the town's small businesses. Enjoy
|
||||
Illinois and Discover Peoria have both pointed travelers our way. If you are one of them,
|
||||
we open at 7:00am, Tuesday through Saturday.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content">
|
||||
|
||||
<!--/* Hero — centred lockup on a sage wash. */-->
|
||||
<section class="relative flex items-center min-h-[78svh] py-20 md:py-28 bg-bakery-900 text-white overflow-hidden">
|
||||
<!--/* Softened on purpose: the storefront's own painted sign sits right behind the logo, so a sharp
|
||||
photo makes you read the name twice. scale-110 hides the blur's feathered edges. */-->
|
||||
<img th:src="${photos.of('gallery/Outside.webp')}" alt="" fetchpriority="high"
|
||||
class="absolute inset-0 w-full h-full object-cover object-bottom blur-[3px] scale-110">
|
||||
<!--/* Sage wash rather than a neutral black scrim — the tint is the identity. */-->
|
||||
<div class="absolute inset-0 bg-bakery-900/80"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80"></div>
|
||||
|
||||
<!--/* w-full/min-w-0 keep this flex item from sizing to its max-content width and blowing out the
|
||||
page on narrow screens. */-->
|
||||
<div class="relative container mx-auto px-4 w-full min-w-0">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<div th:replace="~{fragments/lockup :: large('text-bakery-50 mb-10')}"></div>
|
||||
<p class="text-lg sm:text-xl text-bakery-100 mb-10 text-balance leading-relaxed">
|
||||
A coffeehouse and bakery in downtown Princeville, Illinois.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<a href="/products"
|
||||
class="bg-bakery-50 hover:bg-white text-bakery-900 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition shadow-lg">
|
||||
See the menu
|
||||
</a>
|
||||
<a href="#visit"
|
||||
class="border border-bakery-200/60 hover:bg-bakery-50/10 text-bakery-50 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition">
|
||||
Visit us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--/* What people come in for */-->
|
||||
<section class="py-16 md:py-24 bg-bakery-50">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">What people come in for</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8">
|
||||
<div th:each="item : ${ {'Cinnamon Rolls', 'Sugar Cookies', 'Cakes'} }"
|
||||
class="group text-center bg-white rounded-3xl overflow-hidden shadow-xs hover:shadow-lg hover:-translate-y-1 transition duration-300">
|
||||
<div class="overflow-hidden">
|
||||
<!--/* The filenames carry the spaces in these names; SitePhotos encodes them. */-->
|
||||
<img th:src="${photos.of('gallery/' + item + '.webp')}" th:alt="${item}" width="600" height="400" loading="lazy"
|
||||
class="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105">
|
||||
</div>
|
||||
<h3 class="font-adbhashitha text-xl md:text-2xl text-bakery-800 py-6 tracking-wide" th:text="${item}">Cinnamon Rolls</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--/* Our story */-->
|
||||
<section class="py-16 md:py-24 bg-bakery-800 text-white">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<h2 class="font-adbhashitha text-3xl md:text-4xl mb-8" style="letter-spacing: 0.01em">Our story</h2>
|
||||
<p class="text-base md:text-lg text-bakery-100 mb-8 leading-relaxed">
|
||||
Morissa Bennett opened The Vine in 2024. We bake in our own kitchen on Main Street:
|
||||
cinnamon rolls, cookies, custom cakes, sandwiches, paninis, and coffee.
|
||||
</p>
|
||||
<a href="/history" class="text-white hover:text-bakery-200 font-semibold underline underline-offset-4">
|
||||
Read our story
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--/* Visit us */-->
|
||||
<section id="visit" class="py-16 md:py-24 bg-bakery-50 scroll-mt-24">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">Visit us</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-4xl mx-auto">
|
||||
<div class="bg-white rounded-3xl p-6 md:p-8">
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Our hours</h2>
|
||||
<ul class="space-y-3 text-bakery-800">
|
||||
<li class="flex justify-between gap-4">
|
||||
<span>Tuesday – Friday</span>
|
||||
<span class="font-medium whitespace-nowrap">7:00am – 2:00pm</span>
|
||||
</li>
|
||||
<li class="flex justify-between gap-4">
|
||||
<span>Saturday</span>
|
||||
<span class="font-medium whitespace-nowrap">7:00am – 12:00pm</span>
|
||||
</li>
|
||||
<li class="flex justify-between gap-4">
|
||||
<span>Sunday – Monday</span>
|
||||
<span class="font-medium">Closed</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-white rounded-3xl p-6 md:p-8">
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Find us</h2>
|
||||
<address class="not-italic space-y-3 text-bakery-800">
|
||||
<p>215 E Main Street<br>Princeville, IL 61559</p>
|
||||
<p>
|
||||
<a href="tel:+13097010660" class="hover:text-bakery-600 underline underline-offset-4">(309) 701-0660</a>
|
||||
</p>
|
||||
<p class="break-words">
|
||||
<a href="mailto:[email protected]" class="hover:text-bakery-600 underline underline-offset-4">[email protected]</a>
|
||||
</p>
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="min-h-screen bg-bakery-50">
|
||||
|
||||
<header class="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900">Our products</h1>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16">
|
||||
<!--/*
|
||||
The category filter: links, not buttons. Each filtered view is now a URL you can bookmark or send
|
||||
to somebody, the browser's back button does what it looks like it does, and a crawler can see all
|
||||
forty items instead of whichever twelve the default filter showed. The filtering itself has always
|
||||
been the server's job — this just stopped pretending otherwise.
|
||||
*/-->
|
||||
<div class="flex flex-wrap justify-center gap-4 mb-12">
|
||||
<a th:each="category : ${categories}"
|
||||
th:href="${category == 'All' ? '/products' : '/products?category=' + #uris.escapeQueryParam(category)}"
|
||||
th:text="${category}"
|
||||
th:aria-current="${category == selected ? 'page' : null}"
|
||||
th:class="'px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors '
|
||||
+ (${category == selected}
|
||||
? 'bg-bakery-600 text-white border-bakery-600'
|
||||
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100')">All</a>
|
||||
</div>
|
||||
|
||||
<!--/* Deliberately unanimated, as before: filtering used to run a layout reflow plus an enter/exit
|
||||
fade on every card, which on a 40-card grid reads as the page lurching rather than
|
||||
responding. The only motion left is the shadow on hover. */-->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div th:each="product : ${products}"
|
||||
class="group bg-white rounded-3xl overflow-hidden shadow-xs transition-shadow duration-300 hover:shadow-lg">
|
||||
<div class="relative w-full overflow-hidden">
|
||||
<div th:replace="~{:: gallery(${product})}"></div>
|
||||
</div>
|
||||
<div class="p-6 text-center">
|
||||
<h3 class="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide" th:text="${product.name}">Name</h3>
|
||||
<span class="text-xs uppercase tracking-[0.15em] text-bakery-600" th:text="${product.category}">Category</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p th:if="${#lists.isEmpty(products)}" class="text-center text-bakery-800">
|
||||
Nothing in that category just now.
|
||||
<a href="/products" class="underline underline-offset-4">See everything</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--/*
|
||||
The photo on a card.
|
||||
|
||||
A scroll-snap strip rather than a stack of absolutely positioned images cross-fading: it is one square
|
||||
photo at a time either way, but this version swipes on a phone and scrolls with a trackpad with no
|
||||
script at all. /js/gallery.js adds the arrows and the dots on top. They are created there rather than
|
||||
rendered here on purpose — a control that does nothing without JavaScript is worse than no control.
|
||||
*/-->
|
||||
<div th:fragment="gallery(product)" class="relative aspect-square bg-bakery-100">
|
||||
<div class="gallery flex h-full w-full overflow-x-auto snap-x snap-mandatory no-scrollbar"
|
||||
th:attr="aria-label=${#lists.size(product.images) > 1 ? product.name + ' — ' + #lists.size(product.images) + ' photos' : null}"
|
||||
th:aria-roledescription="${#lists.size(product.images) > 1 ? 'carousel' : null}">
|
||||
<img th:each="image, i : ${product.images}" th:src="${image}"
|
||||
th:alt="${i.index == 0 ? product.name : ''}"
|
||||
th:aria-hidden="${i.index == 0 ? null : 'true'}"
|
||||
loading="lazy" decoding="async"
|
||||
class="snap-center shrink-0 h-full w-full object-cover">
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user