Rewrite on the Bennett platform: Spring Boot + Vite/React SPA
build-and-publish / build (push) Successful in 1m9s

Replaces the Next.js app. Same site, same look; the parts that were decisions rather
than markup now live in Java.

- catalogue, curated order, category filter and image URLs move from a TypeScript array
  into Postgres behind /api/products and /api/categories
- contact form uses the shared platform-starter-contact: validate, RECORD, send, then
  fan out to n8n. Recording first means a relay outage costs a notification, not an enquiry
- PageMetaController rewrites title/description/OG per route, replacing what Next's SSR
  gave crawlers and link-preview scrapers
- 50MB of photos leave the repo for the MinIO bucket, re-encoded to webp (14MB) with EXIF
  (including phone GPS) stripped
- fixes a catalogue typo: 'Strawberry Pie' was category 'Pies', which no filter matched, so
  it was unreachable unless browsing All

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
This commit is contained in:
2026-07-22 22:09:51 -05:00
co-authored by Claude Opus 4.8
parent 3f9efa178a
commit 11ae5f378d
164 changed files with 4223 additions and 14003 deletions
+7 -10
View File
@@ -1,10 +1,7 @@
node_modules target/
.next frontend/node_modules/
.git frontend/dist/
npm-debug.log .git/
.env .idea/
.env.local *.iml
Dockerfile .vscode/
.dockerignore
README.md
default.nix
-16
View File
@@ -1,16 +0,0 @@
# SMTP credentials for the contact form. Copy to .env and fill in.
# .env is gitignored — never commit real values.
SMTP_SERVER=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_TOKEN=
# Where contact form submissions are delivered.
CONTACT_TO=[email protected]
# Envelope sender. Many SMTP providers require this to match SMTP_USERNAME or a verified domain.
CONTACT_FROM=
# Optional: automation hub (n8n) webhook. When set, each enquiry is also POSTed here
# (fire-and-forget) so the hub can send the customer auto-reply and create CRM/task
# records. The direct email above stays the reliable path; leave blank to disable.
CONTACT_HUB_URL=
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>gitea</id>
<username>${env.MAVEN_USER}</username>
<password>${env.MAVEN_TOKEN}</password>
</server>
</servers>
<profiles>
<profile>
<id>gitea</id>
<repositories>
<repository>
<id>gitea</id>
<url>https://git.thebennett.net/api/packages/austin/maven</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
</profile>
</profiles>
<activeProfiles><activeProfile>gitea</activeProfile></activeProfiles>
</settings>
+49
View File
@@ -0,0 +1,49 @@
name: build-and-publish
on:
push:
branches: [main]
# Files that can't change the image. Skipping them avoids a pointless rebuild that Watchtower
# would then redeploy — a few seconds of downtime on a live site for a docs-only commit.
paths-ignore: ["renovate.json", "**.md"]
# Lets `rebuild-all-apps.sh` force a rebuild (e.g. to roll out an urgent platform fix immediately
# instead of waiting for a Renovate bump PR).
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# The platform is now referenced by an immutable RELEASE version, so a cached maven layer can't
# silently hold an old build — layer caching is safe again (and much faster).
- name: Build image
env:
DOCKER_BUILDKIT: "1"
MAVEN_USER: ${{ secrets.REGISTRY_USER }}
MAVEN_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
docker build \
--secret id=maven_user,env=MAVEN_USER \
--secret id=maven_token,env=MAVEN_TOKEN \
--build-arg GIT_SHA=${{ github.sha }} \
-t git.thebennett.net/thevine/itsthevine:latest \
-t git.thebennett.net/thevine/itsthevine:${{ github.sha }} .
- name: Scan image (Trivy)
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --no-progress \
git.thebennett.net/thevine/itsthevine:latest || true
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \
--pkg-types library --exit-code 1 --no-progress \
git.thebennett.net/thevine/itsthevine:latest
- name: Push image
run: |
docker push git.thebennett.net/thevine/itsthevine:latest
docker push git.thebennett.net/thevine/itsthevine:${{ github.sha }}
-25
View File
@@ -1,25 +0,0 @@
name: build-and-publish
on:
push:
branches: [master, main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to the Gitea container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build image
run: |
docker build \
-t git.thebennett.net/thevine/itsthevine:latest \
-t git.thebennett.net/thevine/itsthevine:${{ github.sha }} .
- name: Push image
run: |
docker push git.thebennett.net/thevine/itsthevine:latest
docker push git.thebennett.net/thevine/itsthevine:${{ github.sha }}
+6 -40
View File
@@ -1,41 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. target/
frontend/node_modules/
# dependencies frontend/dist/
/node_modules .idea/
/.pnp *.iml
.pnp.* .vscode/
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store .DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for commiting if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+32 -25
View File
@@ -1,30 +1,37 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Multi-stage build for the Next.js (standalone) itsthevine.com site.
FROM node:22-alpine AS deps # ---------- build ----------
WORKDIR /app FROM maven:3.9-eclipse-temurin-25 AS build
COPY package.json package-lock.json ./ WORKDIR /src
# framer-motion@11 declares a React 18 peer; project is on React 19 (as built on Vercel)
RUN npm ci --legacy-peer-deps
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 # Resolve the platform from the Gitea Maven registry (creds via BuildKit secrets); the Maven build also
RUN npm run build # runs the Vite/React SPA build (frontend-maven-plugin) and folds it into the jar. Tests are skipped here
# because Testcontainers needs a Docker daemon — they run via `mvn verify`, not in the image build.
RUN --mount=type=secret,id=maven_user --mount=type=secret,id=maven_token \
MAVEN_USER="$(cat /run/secrets/maven_user)" MAVEN_TOKEN="$(cat /run/secrets/maven_token)" \
mvn -B -ntp -s .gitea/ci-settings.xml -DskipTests package \
&& cp "$(ls target/itsthevine-*.jar | grep -v original | head -1)" app.jar \
&& java -Djarmode=tools -jar app.jar extract --layers --destination extracted
FROM node:22-alpine AS runner # ---------- runtime ----------
FROM eclipse-temurin:25-jre-alpine AS runtime
RUN apk -U upgrade --no-cache && apk add --no-cache curl
RUN addgroup -S spring && adduser -S -D -H -h /app -s /sbin/nologin -G spring spring
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \ COPY --from=build --chown=spring:spring /src/extracted/dependencies/ ./
PORT=3000 \ COPY --from=build --chown=spring:spring /src/extracted/snapshot-dependencies/ ./
HOSTNAME=0.0.0.0 COPY --from=build --chown=spring:spring /src/extracted/application/ ./
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# standalone output already contains a minimal node_modules + server.js USER spring
COPY --from=builder /app/public ./public EXPOSE 8080
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
USER nextjs CMD curl -fsS http://localhost:8080/actuator/health || exit 1
EXPOSE 3000
CMD ["node", "server.js"] ARG GIT_SHA=unknown
LABEL org.opencontainers.image.title="itsthevine" \
org.opencontainers.image.source="https://git.thebennett.net/thevine/itsthevine" \
org.opencontainers.image.revision="${GIT_SHA}"
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
+63 -5
View File
@@ -1,11 +1,69 @@
# [The Vine Coffeehouse & Bakery](http://itsthevine.com) # The Vine Coffeehouse + Bakeryitsthevine.com
## Getting Started Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot serving a Vite/React SPA,
on [the Bennett platform](https://git.thebennett.net/austin/platform).
First, run the development server: Previously a Next.js app on Cloudflare, then self-hosted; the look is unchanged.
## Shape
| | |
|---|---|
| Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` |
| Frontend | Vite + React 19 + TypeScript + Tailwind v4, served from the jar |
| Database | Postgres (`itsthevine` on the shared `app-db` cluster), Flyway |
| Photos | public MinIO bucket `itsthevine`**not** in the repo or the image |
| Deploy | Gitea CI → image → Watchtower → Caddy |
## What the server owns
The SPA renders; it doesn't decide anything.
- **`/api/products`**, **`/api/categories`** — the catalogue, its curated order, the category filter
and the absolute image URLs. This was a TypeScript array shipped to every visitor; it's now a table
(`V2__products.sql`) read through `ProductCatalog`.
- **`/api/contact`** — validates, **records the enquiry**, emails it, then fans out to the n8n hub.
Recorded before sending on purpose: a relay outage costs a notification, not the enquiry. Undelivered
ones are `enquiry.delivered = false`. Validation and delivery come from `platform-starter-contact`,
shared with the other sites.
- **Per-page metadata** — `PageMetaController` rewrites `<title>`/`<meta>`/OG tags per route. Next used
to server-render these; a plain SPA would hand crawlers and link-preview scrapers one generic shell.
## Photos
Re-encoded to webp and uploaded to the bucket once (50 MB of originals → 14 MB), served with a
year-long cache. `site.assets.base-url` says where they live. The originals remain in this repo's
history. EXIF (including GPS from phone photos) is stripped by the re-encode.
## Local development
```bash ```bash
npm run dev # backend (needs Postgres on :5432 with an itsthevine database)
mvn spring-boot:run
# frontend, proxies /api to :8080
cd frontend && npm install && npm run dev # http://localhost:2024
``` ```
Open http://localhost:3000 with your browser to see the result. Build without the SPA for quick backend loops: `mvn -DskipFrontend=true package`.
Tests need Docker (Testcontainers):
```bash
mvn verify
```
## Configuration
| Variable | Purpose |
|---|---|
| `DB_URL` / `DB_USER` / `DB_PASSWORD` | Postgres |
| `SMTP_SERVER` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_TOKEN` | relay for the contact form |
| `CONTACT_TO` / `CONTACT_FROM` | enquiry recipient and envelope sender |
| `CONTACT_HUB_URL` | optional n8n webhook; best-effort, never blocks a submission |
| `SITE_BASE_URL` | absolute base for `og:url` |
| `VITE_ASSET_BASE` / `site.assets.base-url` | photo bucket |
`platform.contact.to` unset disables the contact starter, and the app won't start with a
`ContactController` that has nothing to send with — so a missing `CONTACT_TO` fails loudly rather than
silently dropping enquiries.
-15
View File
@@ -1,15 +0,0 @@
let
pkgs = import <nixpkgs> {};
in
pkgs.mkShell {
buildInputs = [
pkgs.nodejs
];
shellHook = ''
if ! [ -d "node_modules" ]; then
npm install --legacy-peer-deps
fi
'';
}
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
<!-- Preconnect to the photo bucket so product images start loading a round-trip sooner. -->
<link rel="preconnect" href="https://s3.thebennett.net" crossorigin>
<!-- PageMetaController rewrites the title and the four meta tags below per route, so crawlers and
link-preview scrapers get real per-page metadata instead of one generic shell. Keep the
attribute order and quoting as-is — it matches on them. -->
<title>The Vine Coffeehouse + Bakery</title>
<meta name="description" content="A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake pastries, custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, and coffee.">
<meta property="og:title" content="The Vine Coffeehouse + Bakery">
<meta property="og:description" content="A locally owned coffeehouse and bakery in downtown Princeville, IL.">
<meta property="og:url" content="https://itsthevine.com">
<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">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2466
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "itsthevine-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "12.34.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "7.18.1"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"tailwindcss": "4.3.3",
"typescript": "^5.9.0",
"vite": "^8.1.3",
"vite-plugin-svgr": "^4.5.0"
}
}

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+40
View File
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import HomePage from '@/pages/Home';
import ProductsPage from '@/pages/Products';
import HistoryPage from '@/pages/History';
import ContactPage from '@/pages/Contact';
import NotFoundPage from '@/pages/NotFound';
/**
* Client-side navigation keeps the previous scroll position, which lands you halfway down a page you
* just opened. Anchors like /#visit still need to work, so only reset when there isn't one.
*/
const ScrollToTop = () => {
const { pathname, hash } = useLocation();
useEffect(() => {
if (!hash) window.scrollTo(0, 0);
}, [pathname, hash]);
return null;
};
const App = () => (
<div className="min-h-screen bg-bakery-50 flex flex-col">
<ScrollToTop />
<Header />
<main className="flex-grow">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
<Footer />
</div>
);
export default App;

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

@@ -1,4 +1,4 @@
import Link from 'next/link'; import { Link } from 'react-router-dom';
import Logo from './Logo'; import Logo from './Logo';
const Footer = () => { const Footer = () => {
@@ -16,13 +16,13 @@ const Footer = () => {
<h3 className="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Navigation</h3> <h3 className="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Navigation</h3>
<ul className="space-y-2"> <ul className="space-y-2">
<li> <li>
<Link href="/products" className="hover:text-white transition">Our Products</Link> <Link to="/products" className="hover:text-white transition">Our Products</Link>
</li> </li>
<li> <li>
<Link href="/history" className="hover:text-white transition">Our Story</Link> <Link to="/history" className="hover:text-white transition">Our Story</Link>
</li> </li>
<li> <li>
<Link href="/contact" className="hover:text-white transition">Contact</Link> <Link to="/contact" className="hover:text-white transition">Contact</Link>
</li> </li>
</ul> </ul>
</div> </div>
@@ -1,7 +1,6 @@
'use client'
import { useState } from 'react'; import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import Link from 'next/link'; import { Link } from 'react-router-dom';
import Logo from './Logo'; import Logo from './Logo';
const Header = () => { const Header = () => {
@@ -24,7 +23,7 @@ const Header = () => {
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} to={item.href}
className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition" className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition"
> >
{item.label} {item.label}
@@ -87,7 +86,7 @@ const Header = () => {
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} to={item.href}
className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg" className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg"
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
> >
@@ -104,4 +103,4 @@ const Header = () => {
); );
}; };
export default Header; export default Header;
@@ -1,6 +1,6 @@
import Link from 'next/link'; import { Link } from 'react-router-dom';
import Logo_R from 'public/images/resources/logo_R.svg'; import Logo_R from '@/assets/logo_R.svg?react';
import Logo_L from 'public/images/resources/logo_L.svg'; import Logo_L from '@/assets/logo_L.svg?react';
interface LogoProps { interface LogoProps {
/** Tailwind text-* class. The SVG marks fill with currentColor, so this colors the whole lockup. */ /** Tailwind text-* class. The SVG marks fill with currentColor, so this colors the whole lockup. */
@@ -55,7 +55,7 @@ const Logo: React.FC<LogoProps> = ({ className = 'text-bakery-700', size = 'sm',
} }
return ( return (
<Link href="/" className={classes} aria-label="The Vine Coffeehouse + Bakery, home"> <Link to="/" className={classes} aria-label="The Vine Coffeehouse + Bakery, home">
{inner} {inner}
</Link> </Link>
); );
@@ -0,0 +1,63 @@
import { useState } from 'react';
interface ProductGalleryProps {
images: string[];
alt: string;
}
/**
* The square photo on a product card, with arrows when there's more than one shot.
*
* Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to
* React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a
* build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop,
* arrows only when they'd do something.
*/
const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
const [index, setIndex] = useState(0);
const many = images.length > 1;
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
return (
<div className="relative aspect-square bg-bakery-100">
{images.map((src, i) => (
<img
key={src}
src={src}
// Only the visible frame gets described; the rest are decorative duplicates of the same item.
alt={i === 0 ? alt : ''}
loading="lazy"
decoding="async"
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
i === index ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
aria-hidden={i === index ? undefined : true}
/>
))}
{many && (
<>
<button
type="button"
onClick={() => step(-1)}
aria-label={`Previous photo of ${alt}`}
className="absolute left-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
<span aria-hidden="true">{'<'}</span>
</button>
<button
type="button"
onClick={() => step(1)}
aria-label={`Next photo of ${alt}`}
className="absolute right-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
<span aria-hidden="true">{'>'}</span>
</button>
</>
)}
</div>
);
};
export default ProductGallery;
Binary file not shown.
+90
View File
@@ -0,0 +1,90 @@
@import "tailwindcss";
/* Brand fonts ship with the app rather than coming from Google — same look, no third-party request
on every page load. Raleway is the variable latin subset. */
@font-face {
font-family: 'Raleway';
src: url('./fonts/raleway-latin.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'AdBhashitha';
src: url('./fonts/AdBhashitha.woff') format('woff');
font-display: swap;
}
@font-face {
font-family: 'LeJour Script';
src: url('./fonts/LeJour-Script.woff') format('woff');
font-display: swap;
}
@theme {
/* Sage & Cream. 500 is the signature sage; 600+ are the darker tones that white text can actually
sit on (500 on white is only 3.6:1 — too low). */
--color-bakery-50: #faf7f0;
--color-bakery-100: #f0efe3;
--color-bakery-200: #dde0cc;
--color-bakery-300: #c3cbae;
--color-bakery-400: #a2ae8b;
--color-bakery-500: #7c8b6b;
--color-bakery-600: #5f6f52;
--color-bakery-700: #4a5740;
--color-bakery-800: #37412f;
--color-bakery-900: #232b1e;
--font-sans: 'Raleway', ui-sans-serif, system-ui, sans-serif;
--font-adbhashitha: 'AdBhashitha', ui-serif, Georgia, serif;
--font-lejour: 'LeJour Script', ui-serif, Georgia, cursive;
}
html {
scroll-behavior: smooth;
/* Nothing on this site is meant to scroll sideways. */
overflow-x: hidden;
}
body {
background-color: var(--color-bakery-50);
color: var(--color-bakery-900);
font-family: var(--font-sans);
overflow-x: hidden;
opacity: 0;
animation: fadeIn 0.5s ease-in forwards;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
body { animation: none; opacity: 1; }
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Deliberately unlayered so it overrides Tailwind's own `container` utility — the site's gutters are
part of the layout, not the breakpoint default. */
.container {
max-width: 80rem;
margin-inline: auto;
padding-inline: 1rem;
}
@media (min-width: 40rem) {
.container { padding-inline: 1.5rem; }
}
@media (min-width: 64rem) {
.container { padding-inline: 2rem; }
}
::selection {
background-color: var(--color-bakery-300);
color: var(--color-bakery-900);
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The catalogue, its ordering, its category filter and its image URLs are all decided by the
* backend — this file just fetches them. Same origin, so no base URL and no CORS.
*/
export interface Product {
id: number;
name: string;
category: string;
/** Absolute, ready to put in a src. Built server-side from the bucket config. */
images: string[];
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(path, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
return res.json() as Promise<T>;
}
export const fetchProducts = (category?: string) =>
get<Product[]>(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
export const fetchCategories = () => get<string[]>('/api/categories');
+17
View File
@@ -0,0 +1,17 @@
/**
* Photos live in the public MinIO bucket, not in the app image — 50 MB of JPEGs has no business
* inside a container we redeploy on every commit, and the bucket serves them with a year-long
* cache. Small brand assets (logo marks, favicons) stay local so first paint needs nothing external.
*/
const BASE = (import.meta.env.VITE_ASSET_BASE ?? 'https://s3.thebennett.net/itsthevine').replace(/\/$/, '');
/**
* `photo('products/scones.webp')` -> absolute bucket URL.
*
* Segments are encoded individually: some gallery files have spaces in their names ("Cinnamon
* Rolls.webp") and a raw space in a URL doesn't fetch.
*/
export function photo(key: string): string {
const path = key.replace(/^\//, '').split('/').map(encodeURIComponent).join('/');
return `${BASE}/images/${path}`;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
@@ -1,4 +1,3 @@
'use client';
import { useState } from 'react'; import { useState } from 'react';
type Status = 'idle' | 'sending' | 'sent' | 'error'; type Status = 'idle' | 'sending' | 'sent' | 'error';
@@ -59,38 +58,38 @@ const ContactPage = () => {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label> <label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
value={formData.name} value={formData.name}
onChange={handleChange} onChange={handleChange}
className="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" className="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"
required required
/> />
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="email">Email</label> <label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="email">Email</label>
<input <input
type="email" type="email"
id="email" id="email"
name="email" name="email"
value={formData.email} value={formData.email}
onChange={handleChange} onChange={handleChange}
className="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" className="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"
required required
/> />
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="message">Message</label> <label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="message">Message</label>
<textarea <textarea
id="message" id="message"
name="message" name="message"
value={formData.message} value={formData.message}
onChange={handleChange} onChange={handleChange}
className="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" className="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"
rows={5} rows={5}
required required
/> />
</div> </div>
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
@@ -1,7 +1,6 @@
'use client' import { Link } from 'react-router-dom';
import Link from 'next/link'; import Logo from '@/components/Logo';
import Image from 'next/image'; import { photo } from '@/lib/assets';
import Logo from './Logo';
const HomePage = () => { const HomePage = () => {
return ( return (
@@ -11,13 +10,11 @@ const HomePage = () => {
{/* Softened on purpose: the storefront's own painted sign sits right behind {/* 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 logo, so a sharp photo makes you read the name twice. scale-110 hides
the blur's feathered edges. */} the blur's feathered edges. */}
<Image <img
src="/images/gallery/Outside.jpg" src={photo('gallery/Outside.webp')}
alt="" alt=""
fill fetchPriority="high"
priority className="absolute inset-0 w-full h-full object-cover object-bottom blur-[3px] scale-110"
quality={90}
className="object-cover object-bottom blur-[3px] scale-110"
/> />
{/* Sage wash rather than a neutral black scrim — the tint is the identity. */} {/* Sage wash rather than a neutral black scrim — the tint is the identity. */}
<div className="absolute inset-0 bg-bakery-900/80" /> <div className="absolute inset-0 bg-bakery-900/80" />
@@ -33,7 +30,7 @@ const HomePage = () => {
</p> </p>
<div className="flex flex-col sm:flex-row gap-3 justify-center"> <div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link <Link
href="/products" to="/products"
className="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" className="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 See the menu
@@ -49,8 +46,6 @@ const HomePage = () => {
</div> </div>
</section> </section>
{/* Our Specialties */} {/* Our Specialties */}
<section className="py-16 md:py-24 bg-bakery-50"> <section className="py-16 md:py-24 bg-bakery-50">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
@@ -62,12 +57,12 @@ const HomePage = () => {
className="group text-center bg-white rounded-3xl overflow-hidden shadow-sm hover:shadow-lg hover:-translate-y-1 transition duration-300" className="group text-center bg-white rounded-3xl overflow-hidden shadow-sm hover:shadow-lg hover:-translate-y-1 transition duration-300"
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<Image <img
src={`/images/gallery/${item}.png`} src={photo(`gallery/${item}.webp`)}
alt={item} alt={item}
width={600} width={600}
height={400} height={400}
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, 33vw" loading="lazy"
className="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105" className="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105"
/> />
</div> </div>
@@ -87,7 +82,7 @@ const HomePage = () => {
Morissa Bennett opened The Vine in 2024. We bake in our own kitchen on Main Street: 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. cinnamon rolls, cookies, custom cakes, sandwiches, paninis, and coffee.
</p> </p>
<Link href="/history" className="text-white hover:text-bakery-200 font-semibold underline underline-offset-4"> <Link to="/history" className="text-white hover:text-bakery-200 font-semibold underline underline-offset-4">
Read our story Read our story
</Link> </Link>
</div> </div>
@@ -135,4 +130,4 @@ const HomePage = () => {
); );
}; };
export default HomePage; export default HomePage;
+30
View File
@@ -0,0 +1,30 @@
import { Link } from 'react-router-dom';
const NotFoundPage = () => (
<div className="bg-bakery-50">
<div className="container mx-auto px-4 py-24 md:py-32 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900 mb-6">
We could not find that page
</h1>
<p className="text-bakery-800 mb-10">
It may have moved. The menu, our story, and how to reach us are all still here.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
to="/"
className="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
</Link>
<Link
to="/products"
className="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
</Link>
</div>
</div>
</div>
);
export default NotFoundPage;
@@ -1,16 +1,28 @@
'use client'; import { useEffect, useState } from 'react';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { products, categories } from '@/data/products'; import { fetchCategories, fetchProducts, type Product } from '@/lib/api';
import 'react-awesome-slider/dist/styles.css'; import ProductGallery from '@/components/ProductGallery';
import AwesomeSlider from 'react-awesome-slider';
const ProductsPage = () => { const ProductsPage = () => {
const [categories, setCategories] = useState<string[]>(['All']);
const [selectedCategory, setSelectedCategory] = useState('All'); const [selectedCategory, setSelectedCategory] = useState('All');
const [products, setProducts] = useState<Product[]>([]);
const [failed, setFailed] = useState(false);
const filteredProducts = selectedCategory === 'All' useEffect(() => {
? products fetchCategories().then(setCategories).catch(() => setFailed(true));
: products.filter(product => product.category === selectedCategory); }, []);
// The filter is applied by the API, not in the browser — one source of truth for what's in a
// category. `ignore` drops a slow response that lost the race to a newer click.
useEffect(() => {
let ignore = false;
setFailed(false);
fetchProducts(selectedCategory)
.then((p) => { if (!ignore) setProducts(p); })
.catch(() => { if (!ignore) setFailed(true); });
return () => { ignore = true; };
}, [selectedCategory]);
return ( return (
<div className="min-h-screen bg-bakery-50"> <div className="min-h-screen bg-bakery-50">
@@ -42,13 +54,20 @@ const ProductsPage = () => {
))} ))}
</div> </div>
{failed && (
<p role="alert" className="text-center text-bakery-800">
We could not load the menu just now. Please refresh, or call us on{' '}
<a href="tel:+13097010660" className="underline underline-offset-4">(309) 701-0660</a>.
</p>
)}
{/* Products Grid */} {/* Products Grid */}
<motion.div <motion.div
layout layout
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
> >
<AnimatePresence> <AnimatePresence>
{filteredProducts.map((product) => ( {products.map((product) => (
<motion.div <motion.div
key={product.id} key={product.id}
layout layout
@@ -59,18 +78,7 @@ const ProductsPage = () => {
> >
{/* Image Container */} {/* Image Container */}
<div className="relative w-full overflow-hidden"> <div className="relative w-full overflow-hidden">
<AwesomeSlider <ProductGallery images={product.images} alt={product.name} />
bullets={false}
style={{ aspectRatio: '1 / 1' }}
organicArrows={product.images.length > 1}
customContent={true}
buttonContentLeft={product.images.length > 1 ? <span className="text-white text-2xl">{'<'}</span> : null}
buttonContentRight={product.images.length > 1 ? <span className="text-white text-2xl">{'>'}</span> : null}
>
{product.images.map((image, index) => (
<div key={index} data-src={image} />
))}
</AwesomeSlider>
</div> </div>
{/* Content */} {/* Content */}
@@ -91,4 +99,4 @@ const ProductsPage = () => {
); );
}; };
export default ProductsPage; export default ProductsPage;
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client", "vite-plugin-svgr/client", "node"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import svgr from 'vite-plugin-svgr';
import path from 'node:path';
export default defineConfig({
// svgr keeps the `import Logo from './x.svg?react'` style the old @svgr/webpack setup used, so the
// logo marks stay inline SVG and inherit currentColor.
plugins: [react(), tailwindcss(), svgr()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: 2024,
// Backend on :8080 during development; the built SPA is served by Spring, same origin.
proxy: {
'/api': 'http://localhost:8080',
},
},
build: {
outDir: 'dist',
// The photos live in MinIO, so what's left is small — a warning here would mean a real regression.
chunkSizeWarningLimit: 600,
},
});
-23
View File
@@ -1,23 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone', // self-contained server bundle for the Docker image
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'itsthevine.com',
},
],
},
turbopack: {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
}
};
export default nextConfig;
-8883
View File
File diff suppressed because it is too large Load Diff
-32
View File
@@ -1,32 +0,0 @@
{
"name": "itsthevine.com",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 2024",
"build": "next build",
"start": "next start -p 2024",
"lint": "next lint"
},
"dependencies": {
"@svgr/webpack": "^8.1.0",
"framer-motion": "^11.11.11",
"next": "^16.2.10",
"nodemailer": "^9.0.3",
"react": "^19.0.0",
"react-awesome-slider": "^4.1.0",
"react-dom": "^19.0.0",
"sass": "^1.83.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^9.39.2",
"eslint-config-next": "15.0.2",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
+108
View File
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-parent</artifactId>
<version>0.1.4</version>
<relativePath/>
</parent>
<groupId>com.itsthevine</groupId>
<artifactId>itsthevine</artifactId>
<version>0.1.0</version>
<name>The Vine Coffeehouse + Bakery</name>
<description>Site for The Vine in Princeville, IL — menu, story, and contact form — on the Bennett platform.</description>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-bom</artifactId>
<version>0.1.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Boot 4.1 no longer manages the raw org.testcontainers:* module versions -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.21.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- Platform releases live in the Gitea Maven registry (anonymous read). Declared here so Renovate
can discover new platform versions and open a bump PR. Maven still needs this repo in
settings.xml for PARENT resolution (see .gitea/ci-settings.xml). -->
<repositories>
<repository>
<id>gitea</id>
<url>https://git.thebennett.net/api/packages/austin/maven</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-web</artifactId>
</dependency>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-data</artifactId>
</dependency>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-contact</artifactId>
</dependency>
<!-- No starter-security: this is a public brochure site with nothing to sign in to, and no
starter-storage: the photos are served straight from the public MinIO bucket. -->
<!-- test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- SPA build inherited from platform-parent (node install + npm build + copy dist -> jar). -->
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Some files were not shown because too many files have changed in this diff Show More