diff --git a/Dockerfile b/Dockerfile index c9a05ff..b967ab8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,5 +36,8 @@ 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}" +# Also the cache-buster on the stylesheet URL: one hand-written CSS file has no content hash in its +# name, so a deploy has to tell the browser that what it cached is stale (see site.build). +ENV GIT_SHA=${GIT_SHA} ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] diff --git a/README.md b/README.md index a41d880..b5831dc 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,74 @@ # The Vine Coffeehouse + Bakery — itsthevine.com -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). +Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot rendering its own pages with +Thymeleaf, on [the Bennett platform](https://git.thebennett.net/austin/platform). -Previously a Next.js app on Cloudflare, then self-hosted; the look is unchanged. +Previously a Next.js app on Cloudflare, then a React SPA on Spring, now server-rendered. The look has +not changed through any of it. ## Shape | | | |---|---| | Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` | -| Frontend | Vite + React 19 + TypeScript + Tailwind v4, served from the jar | +| Pages | Thymeleaf, `src/main/resources/templates` — **no JavaScript** except one 100-line file for the product-card arrows | +| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css` | +| Admin | the one React screen that is left, served at `/admin` only | | 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 | +### Why server-rendered + +The pages are content: a menu, a story, opening hours, a price list. Rendering them in the browser meant +shipping a router and a component tree to show them, and it meant `PageMetaController` — a class whose +only job was to splice per-page `` and OG tags into one shell with regular expressions, because a +crawler or a link-preview scraper got nothing useful otherwise. A page that is rendered on the server +writes its own head, so that whole mechanism is deleted rather than ported. The category filter is a +`?category=` link instead of a click handler, which also makes every filtered view a URL you can send +someone, and the contact form is a form post. + +`platform.web.spa.enabled=false` follows from that: the platform's fallback forwards extension-less paths +to `/index.html`, which now holds nothing but the admin. `SiteController` maps `/admin` to it explicitly. + ## What the server owns -The SPA renders; it doesn't decide anything. +Everything. The pages arrive complete. - **`/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/catering`** — the goodie box and catering price tables (Office, Parties, Weddings): the +- **`/catering`** — the goodie box and catering page. Each table is rendered twice from the same model + and CSS shows one: a real `<table>` on a wide screen, because that is what a price list is and a screen + reader then announces the size and the item together; stacked cards on a phone, because a four-column + price table there is either illegible or a sideways scroll, and this page is mostly read on phones. +- **`/api/catering`** — the same tables as JSON: the columns, the prices already written the way they should be read, the entries under each column, and the small print. These came from the bakery as a spreadsheet and are stored as one (`V4__catering.sql`, read through `CateringMenu`) rather than as markup, because the prices move and the last line of that spreadsheet says the tables are "mostly just an idea for people". `Money` is the only thing that decides what a typed price means or how it prints. A table with no columns or no lines is left off the public response — adding a table and filling it in are two separate acts in the admin, and the gap - between them shouldn't put a bare heading on the live page. *(No public page renders this yet.)* + between them shouldn't put a bare heading on the live page. +- **`/contact`** — the form posts here and gets a page back. It renders rather than redirects on failure, + so a refused relay comes back with what the visitor typed still in the boxes: they wrote it once, and + the failure is ours. `/api/contact` still exists and answers JSON; both go through `Enquiries`, so + there is one order of operations for taking an enquiry. - **`/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. +- **Per-page metadata** — each route states its own title and description in `SiteController`, next to + the handler that serves it, and `fragments/head.html` lays them out. `SiteControllerTest` asserts the + real `<title>` of every page. ## /admin +**The last React in the repo.** The public pages are server-rendered; this screen is a Vite/React app +because it is not content — it is an editor, and the instant-feedback editing (reorder that applies +before the network answers, a whole price table arranged on screen and saved in one go) is the point of +it. Everything under `frontend/` builds only this, plus the site's stylesheet. + The catalogue is editable from the site: add an item with a photo and a name, reorder it, rename or reorder the category filters. Nothing there needs a deploy or a migration — which is the point, since the person adding a cake is the person who baked it. @@ -72,14 +102,22 @@ history. EXIF (including GPS from phone photos) is stripped by the re-encode. ## Local development ```bash -# backend (needs Postgres on :5432 with an itsthevine database) -mvn spring-boot:run +# the whole site (needs Postgres on :5432 with an itsthevine database) +mvn spring-boot:run # http://localhost:8080 -# frontend, proxies /api to :8080 -cd frontend && npm install && npm run dev # http://localhost:2024 +# just the stylesheet, while editing templates — watches and recompiles +cd frontend && npm install && npx tailwindcss -i site.css -o ../target/classes/static/css/site.css --watch + +# the admin screen, proxying /api to :8080 +cd frontend && npm run dev # http://localhost:2024/admin ``` -Build without the SPA for quick backend loops: `mvn -DskipFrontend=true package`. +`mvn spring-boot:run` compiles the stylesheet on the way (the Tailwind step is bound to +`process-classes` for exactly that reason). `-DskipFrontend=true` skips both frontend steps for a fast +backend loop — the pages then render **unstyled** until you build the CSS once. + +Templates are cached by default, so a template edit needs a restart; add +`spring.thymeleaf.cache=false` to a local run if you are editing markup. Tests need Docker (Testcontainers): @@ -96,7 +134,8 @@ mvn verify | `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 | +| `site.assets.base-url` | photo bucket. Server-side only now — the browser is handed finished URLs | +| `GIT_SHA` | passed by the image build; becomes `?v=` on the stylesheet so a deploy invalidates the cached CSS | | `SECURITY_MODE` | `OIDC` turns on Authentik login **and brings `/admin` into existence**. Unset = brochure site, no admin | | `STORAGE_ENDPOINT` / `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` / `STORAGE_BUCKET` | MinIO, for admin photo uploads. Blank endpoint leaves storage switched off | diff --git a/frontend/index.html b/frontend/index.html index 56edbf0..a494fc0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,22 +5,11 @@ <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> - <!-- The wordmark is set in these; without preloading they arrive late and the logo visibly reflows. --> - <link rel="preload" as="font" type="font/woff2" href="/src/fonts/raleway-latin.woff2" crossorigin> - <link rel="preload" as="image" href="https://s3.thebennett.net/itsthevine/images/gallery/Outside.webp" fetchpriority="high"> - <!-- 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 - - - - - - - + + + The Vine — admin
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e0a68d..bd90f7d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,11 @@ "name": "itsthevine-frontend", "version": "0.1.0", "dependencies": { - "motion": "12.42.2", "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-router-dom": "7.18.1" + "react-dom": "^19.2.7" }, "devDependencies": { + "@tailwindcss/cli": "4.3.3", "@tailwindcss/vite": "4.3.3", "@types/node": "^26.1.0", "@types/react": "^19.2.17", @@ -21,248 +20,7 @@ "@vitejs/plugin-react": "^6.0.3", "tailwindcss": "4.3.3", "typescript": "^7.0.0", - "vite": "^8.1.3", - "vite-plugin-svgr": "^5.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" + "vite": "^8.1.3" } }, "node_modules/@emnapi/core": { @@ -378,6 +136,328 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -642,252 +722,23 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "node_modules/@tailwindcss/cli": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.3" }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", - "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", - "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-dynamic-title": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", - "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-em-dimensions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", - "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-react-native-svg": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", - "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-svg-component": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", - "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-preset": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", - "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", - "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", - "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", - "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", - "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", - "@svgr/babel-plugin-transform-svg-component": "8.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/core": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", - "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^8.1.3", - "snake-case": "^3.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/hast-util-to-babel-ast": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", - "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.21.3", - "entities": "^4.4.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/plugin-jsx": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", - "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "@svgr/hast-util-to-babel-ast": "8.0.0", - "svg-parser": "^2.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@svgr/core": "*" + "bin": { + "tailwindcss": "dist/index.mjs" } }, "node_modules/@tailwindcss/node": { @@ -1173,13 +1024,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -1576,149 +1420,17 @@ } } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", - "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=8" } }, "node_modules/csstype": { @@ -1728,24 +1440,6 @@ "dev": true, "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1756,24 +1450,6 @@ "node": ">=8" } }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.395", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", - "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", - "dev": true, - "license": "ISC" - }, "node_modules/enhanced-resolve": { "version": "5.24.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", @@ -1788,46 +1464,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1846,31 +1482,17 @@ } } }, - "node_modules/framer-motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", - "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { - "motion-dom": "^12.42.2", - "motion-utils": "^12.39.0", - "tslib": "^2.4.0" + "to-regex-range": "^5.0.1" }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } + "engines": { + "node": ">=8" } }, "node_modules/fsevents": { @@ -1888,16 +1510,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1905,29 +1517,38 @@ "dev": true, "license": "ISC" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } }, "node_modules/jiti": { "version": "2.7.0", @@ -1939,69 +1560,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2263,33 +1821,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2300,53 +1831,42 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", - "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", - "license": "MIT", - "dependencies": { - "framer-motion": "^12.42.2", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/motion-dom": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", - "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.39.0" - } - }, - "node_modules/motion-utils": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", - "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } }, "node_modules/nanoid": { "version": "3.3.16", @@ -2367,68 +1887,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -2500,54 +1964,6 @@ "react": "^19.2.8" } }, - "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", - "license": "MIT", - "dependencies": { - "react-router": "7.18.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -2588,33 +2004,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2625,13 +2014,6 @@ "node": ">=0.10.0" } }, - "node_modules/svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", - "dev": true, - "license": "MIT" - }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2670,11 +2052,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true }, "node_modules/typescript": { "version": "7.0.2", @@ -2718,37 +2115,6 @@ "dev": true, "license": "MIT" }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -2826,28 +2192,6 @@ "optional": true } } - }, - "node_modules/vite-plugin-svgr": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz", - "integrity": "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.3.0", - "@svgr/core": "^8.1.0", - "@svgr/plugin-jsx": "^8.1.0" - }, - "peerDependencies": { - "vite": ">=3.0.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" } } } diff --git a/frontend/package.json b/frontend/package.json index fb9bde5..29174e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,15 +6,15 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "build:css": "tailwindcss -i site.css -o ../target/classes/static/css/site.css --minify", "preview": "vite preview" }, "dependencies": { - "motion": "12.42.2", "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-router-dom": "7.18.1" + "react-dom": "^19.2.7" }, "devDependencies": { + "@tailwindcss/cli": "4.3.3", "@tailwindcss/vite": "4.3.3", "@types/node": "^26.1.0", "@types/react": "^19.2.17", @@ -22,7 +22,6 @@ "@vitejs/plugin-react": "^6.0.3", "tailwindcss": "4.3.3", "typescript": "^7.0.0", - "vite": "^8.1.3", - "vite-plugin-svgr": "^5.0.0" + "vite": "^8.1.3" } } diff --git a/frontend/site.css b/frontend/site.css new file mode 100644 index 0000000..e44b5d6 --- /dev/null +++ b/frontend/site.css @@ -0,0 +1,22 @@ +/* + * The stylesheet for the server-rendered site. + * + * Compiled by the Tailwind CLI (`npm run build:css`) straight into target/classes/static/css: it is a + * source file, not a resource, and the generated stylesheet belongs in the build output rather than in + * src/main/resources next to it. + * + * It lives in this directory, beside the admin's stylesheet, because Tailwind resolves `@import + * "tailwindcss"` by walking up from the CSS file looking for node_modules — and node_modules is here. + * So this directory is the whole asset pipeline: one Tailwind, two stylesheets, one of them for pages + * that contain no JavaScript at all. + * + * @source points Tailwind at the templates, and at gallery.js — the product-card arrows are created in + * script, so their classes are only written down there. A utility exists in the output only if Tailwind + * saw it in one of these files, which is why a class name must never be assembled from pieces at + * runtime. + */ +@import "tailwindcss"; +@import "./src/tokens.css"; + +@source "../src/main/resources/templates"; +@source "../src/main/resources/static/js"; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0e84f3d..fd1caa7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,66 +1,20 @@ -import { useEffect } from 'react'; -import { Outlet, 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'; import AdminPage from '@/pages/Admin'; /** - * 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. + * What's left of the React app: the admin screen, and nothing else. + * + * The shop front is server-rendered Thymeleaf now, so there are no client-side routes to route + * between — react-router went with the pages it used to switch. Spring serves this shell at /admin and + * only at /admin; every other URL is a page in src/main/resources/templates. + * + * The admin sits outside the public chrome deliberately: the nav would offer a signed-in editor links + * away from unsaved work, and the opening hours in the footer are noise on a screen whose whole job is + * the catalogue. */ -const ScrollToTop = () => { - const { pathname, hash } = useLocation(); - useEffect(() => { - // 'instant' overrides the page's scroll-behavior:smooth, which is meant for the #visit - // anchor, not for landing on a new page. - if (!hash) window.scrollTo({ top: 0, behavior: 'instant' }); - }, [pathname, hash]); - return null; -}; - -/** The shop front: the nav, the footer, and the pages a customer sees. */ -const PublicLayout = () => ( -
-
-
- -
-
-); - -/** - * The admin sits outside the public chrome deliberately. It isn't a page you'd browse to — the nav - * would offer a signed-in editor links away from unsaved work, and the opening hours in the footer - * are noise on a screen whose whole job is the catalogue. - */ -const AdminLayout = () => ( -
- -
-); - const App = () => ( - <> - - - }> - } /> - } /> - } /> - } /> - } /> - - }> - } /> - - - +
+ +
); export default App; diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx deleted file mode 100644 index 2b42224..0000000 --- a/frontend/src/components/Footer.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Link } from 'react-router-dom'; -import Logo from './Logo'; - -const Footer = () => { - return ( - - ); -}; - -export default Footer; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx deleted file mode 100644 index bd3ab89..0000000 --- a/frontend/src/components/Header.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useEffect, useState } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; -import { Link, useLocation } from 'react-router-dom'; -import Logo from './Logo'; - -const navItems = [ - { label: 'Our Products', href: '/products' }, - { label: 'Our Story', href: '/history' }, - { label: 'Contact', href: '/contact' }, -]; - -const Header = () => { - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const { pathname } = useLocation(); - - // Close on navigation — without this the panel stays up over the page you just opened. - useEffect(() => setIsMobileMenuOpen(false), [pathname]); - - // Escape closes it, and the page behind it doesn't scroll while it's up. - useEffect(() => { - if (!isMobileMenuOpen) return; - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setIsMobileMenuOpen(false); }; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - window.addEventListener('keydown', onKey); - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', onKey); - }; - }, [isMobileMenuOpen]); - - return ( - <> -
-
-
- {/* Logo */} - - {/* Desktop Navigation */} - - - {/* Mobile menu button — the same control opens and closes, so the bar never - disappears out from under your thumb. */} - -
-
-
- - {/* Mobile navigation. Three things here are load-bearing: - - It lives OUTSIDE
. The header carries `backdrop-blur`, and a backdrop-filter - makes an element a containing block for fixed-position descendants — so a `fixed` panel - nested inside it resolves against the 80px header box, not the viewport, and gets - clipped to a sliver. - - It starts BELOW the bar (`top-20`) instead of covering it, so the logo and the toggle - stay put and the panel needs no second copy of either. One logo, one position, every - breakpoint. - - It must UNMOUNT when closed: a panel parked off-screen still extends the scrollable - area, which is what used to let you scroll sideways and find the menu. */} - - {isMobileMenuOpen && ( - - - - )} - - - ); -}; - -export default Header; diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx deleted file mode 100644 index 6c8a7ce..0000000 --- a/frontend/src/components/Logo.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Link } from 'react-router-dom'; -import Logo_R from '@/assets/logo_R.svg?react'; -import Logo_L from '@/assets/logo_L.svg?react'; - -interface LogoProps { - /** Tailwind text-* class. The SVG marks fill with currentColor, so this colors the whole lockup. */ - className?: string; - size?: 'sm' | 'lg'; - /** The hero sits on the homepage, where a link back to "/" is pointless. */ - linked?: boolean; -} - -// The lockup: branch · "The Vine" over "Coffeehouse + Bakery" · branch. -// Branch heights track the two-line wordmark so the marks read as part of it. -const SIZES = { - sm: { - branch: 'w-12 h-12 sm:w-14 sm:h-14', - name: 'text-xl sm:text-2xl md:text-3xl', - tag: 'text-[0.6rem] sm:text-xs tracking-[0.18em]', - gap: 'gap-1.5 sm:gap-2', - }, - lg: { - branch: 'w-20 h-20 sm:w-28 sm:h-28', - name: 'text-4xl sm:text-5xl md:text-6xl', - tag: 'text-xs sm:text-base tracking-[0.2em]', - gap: 'gap-2 sm:gap-4', - }, -}; - -const Logo: React.FC = ({ className = 'text-bakery-700', size = 'sm', linked = true }) => { - const s = SIZES[size]; - const inner = ( - <> - - - - The Vine - - - Coffeehouse + Bakery - - - - - ); - - const classes = `flex items-center ${s.gap} min-w-0 shrink ${className}`; - - if (!linked) { - return ( -
- {inner} -
- ); - } - - return ( - - {inner} - - ); -}; - -export default Logo; diff --git a/frontend/src/components/ProductGallery.tsx b/frontend/src/components/ProductGallery.tsx deleted file mode 100644 index 95016ad..0000000 --- a/frontend/src/components/ProductGallery.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useRef, useState } from 'react'; - -interface ProductGalleryProps { - images: string[]; - alt: string; -} - -const Chevron = ({ direction }: { direction: 'left' | 'right' }) => ( - -); - -/** - * The square photo on a product card, with arrows and dots 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. - */ -/** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */ -const SWIPE_THRESHOLD = 40; - -const ProductGallery: React.FC = ({ images, alt }) => { - const [index, setIndex] = useState(0); - - // Filtering swaps the product under a reused component instance, so a stale index can point - // past the new list — every frame then renders at opacity-0 and the card goes blank. Reset - // during render (the React-sanctioned way to derive state from props) rather than in an effect, - // so the correct frame paints on the first pass instead of flashing an empty square. - const [renderedFor, setRenderedFor] = useState(images); - if (renderedFor !== images) { - setRenderedFor(images); - setIndex(0); - } - - const many = images.length > 1; - const active = index < images.length ? index : 0; - - const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length); - - // Swipe on touch devices and arrow keys — the react-awesome-slider this replaced had swipe, and - // the products page is browsed mostly on phones. Vertical drags are left alone so the page still - // scrolls through the card. - const touchStart = useRef<{ x: number; y: number } | null>(null); - const onTouchStart = (e: React.TouchEvent) => { - const t = e.touches[0]; - touchStart.current = { x: t.clientX, y: t.clientY }; - }; - const onTouchEnd = (e: React.TouchEvent) => { - const start = touchStart.current; - touchStart.current = null; - if (!start || !many) return; - const t = e.changedTouches[0]; - const dx = t.clientX - start.x; - const dy = t.clientY - start.y; - if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return; - step(dx < 0 ? 1 : -1); - }; - - const arrowClass = - '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'; - - return ( -
{ - if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); } - if (e.key === 'ArrowRight') { e.preventDefault(); step(1); } - } : undefined} - tabIndex={many ? 0 : undefined} - role={many ? 'group' : undefined} - aria-roledescription={many ? 'carousel' : undefined} - aria-label={many ? `${alt} — ${images.length} photos` : undefined} - > - {images.map((src, i) => ( - {i - ))} - - {many && ( - <> - - - - {/* Dots: how many photos there are, and which one you're on. */} -
- {images.map((src, i) => ( -
- - )} -
- ); -}; - -export default ProductGallery; diff --git a/frontend/src/index.css b/frontend/src/index.css index 8994160..40693f5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,85 +1,13 @@ +/* + * The admin screen's stylesheet. + * + * The brand itself lives in ./tokens.css, shared with the server-rendered + * site's stylesheet — one palette, one set of fonts, and no drift between the shop front and the + * screen that edits it. Everything else the admin needs comes from utility classes in the React + * source, which Tailwind finds by scanning it. + * + * The fonts are served by Spring from /fonts rather than bundled here, so both stylesheets can name + * the same URL. + */ @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); - } -} - -/* In @layer components, matching the old site. That ordering matters: a utility like `px-4` — which - the markup applies alongside `container` on nearly every section — has to win over this, or every - gutter on the site silently widens. */ -@layer components { - .container { - @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; - } -} - -::selection { - background-color: var(--color-bakery-300); - color: var(--color-bakery-900); -} +@import "./tokens.css"; diff --git a/frontend/src/lib/assets.ts b/frontend/src/lib/assets.ts deleted file mode 100644 index 6e6ba6f..0000000 --- a/frontend/src/lib/assets.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 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}`; -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e2d123a..a74ce78 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,11 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; import App from './App'; import './index.css'; +// No BrowserRouter: this shell is served at /admin and nowhere else, so there is nothing to route. createRoot(document.getElementById('root')!).render( - - - + , ); diff --git a/frontend/src/pages/Contact.tsx b/frontend/src/pages/Contact.tsx deleted file mode 100644 index 0a4f66e..0000000 --- a/frontend/src/pages/Contact.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useState } from 'react'; -import { csrfHeader } from '@/lib/api'; - -type Status = 'idle' | 'sending' | 'sent' | 'error'; - -const ContactPage = () => { - const [formData, setFormData] = useState({ - name: '', - email: '', - message: '' - }); - const [status, setStatus] = useState('idle'); - const [error, setError] = useState(''); - - const handleChange = (e: React.ChangeEvent) => { - setFormData({ - ...formData, - [e.target.name]: e.target.value - }); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setStatus('sending'); - setError(''); - try { - // csrfHeader() is empty unless security is switched on, so this posts the same as it always - // has on a deployment with no identity provider — and keeps working once one is configured, - // where an unaccompanied POST would otherwise be rejected. - const res = await fetch('/api/contact', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...csrfHeader() }, - body: JSON.stringify(formData), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Could not send the message.'); - setStatus('sent'); - setFormData({ name: '', email: '', message: '' }); - } catch (err) { - // Never claim success we did not get. Tell them, and give them the phone number. - setStatus('error'); - setError(err instanceof Error ? err.message : 'Could not send the message.'); - } - }; - - return ( -
- {/* Page header */} -
-

- Contact us -

-
- - {/* Contact Form Section */} -
-
-

Get in touch

-

- Or call us: (309) 701-0660 -

-
-
- - -
-
- - -
-
- - +
+
+ + + + +
+
+ +

+ Thanks. Your message is on its way, and we will get back to you. +

+
+
+
+ + diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html new file mode 100644 index 0000000..330e014 --- /dev/null +++ b/src/main/resources/templates/error.html @@ -0,0 +1,29 @@ + + + + +
+
+

Something went wrong

+

+ That is our fault, not yours. Try again in a moment, or call us on + (309) 701-0660. +

+ + Back home + +
+
+ + diff --git a/src/main/resources/templates/error/404.html b/src/main/resources/templates/error/404.html new file mode 100644 index 0000000..367c48d --- /dev/null +++ b/src/main/resources/templates/error/404.html @@ -0,0 +1,39 @@ + + + + +
+
+

+ We could not find that page +

+

+ It may have moved. The menu, our story, and how to reach us are all still here. +

+ +
+
+ + diff --git a/src/main/resources/templates/fragments/footer.html b/src/main/resources/templates/fragments/footer.html new file mode 100644 index 0000000..35254ce --- /dev/null +++ b/src/main/resources/templates/fragments/footer.html @@ -0,0 +1,39 @@ + + + + + + diff --git a/src/main/resources/templates/fragments/head.html b/src/main/resources/templates/fragments/head.html new file mode 100644 index 0000000..d541fce --- /dev/null +++ b/src/main/resources/templates/fragments/head.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + The Vine Coffeehouse + Bakery + + + + + + + + + + + + + diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html new file mode 100644 index 0000000..f32717b --- /dev/null +++ b/src/main/resources/templates/fragments/header.html @@ -0,0 +1,58 @@ + + + + +
+
+
+
+ + + +
+ + + + +
+ +
+
+
+
+
+ + + + Our Products + Catering + Our Story + Contact + + + diff --git a/src/main/resources/templates/fragments/lockup.html b/src/main/resources/templates/fragments/lockup.html new file mode 100644 index 0000000..0c04717 --- /dev/null +++ b/src/main/resources/templates/fragments/lockup.html @@ -0,0 +1,35 @@ + + + + + + + + + The Vine + Coffeehouse + Bakery + + + + + + + + + + +
+ +
+ + diff --git a/src/main/resources/templates/fragments/page.html b/src/main/resources/templates/fragments/page.html new file mode 100644 index 0000000..f6351ba --- /dev/null +++ b/src/main/resources/templates/fragments/page.html @@ -0,0 +1,28 @@ + + + + + +
+
+
+
+
+
+
+ + + + diff --git a/src/main/resources/templates/history.html b/src/main/resources/templates/history.html new file mode 100644 index 0000000..56c4220 --- /dev/null +++ b/src/main/resources/templates/history.html @@ -0,0 +1,54 @@ + + + +
+ +
+

Our story

+
+ +
+
+
+

How it started

+

+ 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. +

+
+ +
+

What we make

+

+ 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. +

+
+ +
+

The cakes are the fun part

+

+ 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. +

+
+ +
+

Around town

+

+ 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. +

+
+
+
+
+ + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..e6f057f --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,112 @@ + + + +
+ + +
+ + + +
+
+ + +
+
+
+

+ A coffeehouse and bakery in downtown Princeville, Illinois. +

+ +
+
+
+ + +
+
+

What people come in for

+
+
+
+ + +
+

Cinnamon Rolls

+
+
+
+
+ + +
+
+
+

Our story

+

+ 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. +

+ + Read our story + +
+
+
+ + +
+
+

Visit us

+
+
+

Our hours

+
    +
  • + Tuesday – Friday + 7:00am – 2:00pm +
  • +
  • + Saturday + 7:00am – 12:00pm +
  • +
  • + Sunday – Monday + Closed +
  • +
+
+
+

Find us

+
+

215 E Main Street
Princeville, IL 61559

+

+ (309) 701-0660 +

+

+ contact@itsthevine.com +

+
+
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/products.html b/src/main/resources/templates/products.html new file mode 100644 index 0000000..90ce582 --- /dev/null +++ b/src/main/resources/templates/products.html @@ -0,0 +1,71 @@ + + + +
+ +
+

Our products

+
+ +
+ +
+ All +
+ + +
+
+
+
+
+
+

Name

+ Category +
+
+
+ +

+ Nothing in that category just now. + See everything. +

+
+
+ + +
+ +
+ + diff --git a/src/test/java/com/itsthevine/web/PageMetaControllerTest.java b/src/test/java/com/itsthevine/web/PageMetaControllerTest.java deleted file mode 100644 index 89e1be1..0000000 --- a/src/test/java/com/itsthevine/web/PageMetaControllerTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.itsthevine.web; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.File; - -import org.junit.jupiter.api.Test; -import org.springframework.core.io.DefaultResourceLoader; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.mock.web.MockHttpServletRequest; - -/** - * Runs against the REAL frontend/index.html rather than a fixture: the controller finds its tags by - * pattern, so reformatting that file is exactly how this would silently break. Here it fails the build - * instead. - */ -class PageMetaControllerTest { - - private static final File INDEX = new File("frontend/index.html"); - - private static PageMetaController controller() { - DefaultResourceLoader loader = new DefaultResourceLoader() { - @Override - public Resource getResource(String location) { - return new FileSystemResource(INDEX); - } - }; - return new PageMetaController(loader, "https://itsthevine.com"); - } - - private static String get(String path) { - MockHttpServletRequest request = new MockHttpServletRequest("GET", path); - request.setRequestURI(path); - return controller().page(request); - } - - @Test - void theIndexTemplateIsWhereTheControllerExpects() { - assertThat(INDEX).exists(); - } - - @Test - void productsPageGetsItsOwnTitleAndDescription() { - String html = get("/products"); - - assertThat(html).contains("Our products · The Vine Coffeehouse + Bakery"); - assertThat(html).contains(""); - } - - @Test - void everyRouteIsRewritten() { - // A route the controller maps but forgot to describe would silently serve the homepage's - // metadata, which is worse than none — it tells a crawler two URLs are the same page. - assertThat(get("/history")).contains("Our story · "); - assertThat(get("/contact")).contains("<title>Contact us · "); - assertThat(get("/")).contains("<title>The Vine Coffeehouse + Bakery"); - } - - @Test - void theHomepageOgUrlHasNoTrailingSlash() { - assertThat(get("/")).contains(""); - } - - @Test - void noDefaultMetadataSurvivesOnASubPage() { - // The template ships with the homepage copy. If a replacement misses, that copy leaks onto - // every page and the whole exercise is pointless. - String html = get("/contact"); - assertThat(html).doesNotContain("A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake"); - assertThat(html).doesNotContain("The Vine Coffeehouse + Bakery"); - } - - @Test - void theAppShellIsStillIntact() { - // Rewriting the head must not disturb what actually boots the SPA. - String html = get("/products"); - assertThat(html).contains("
"); - assertThat(html).contains("/src/main.tsx"); - } -} diff --git a/src/test/java/com/itsthevine/web/PlatformContractTest.java b/src/test/java/com/itsthevine/web/PlatformContractTest.java index e3e1f6b..a167769 100644 --- a/src/test/java/com/itsthevine/web/PlatformContractTest.java +++ b/src/test/java/com/itsthevine/web/PlatformContractTest.java @@ -1,15 +1,42 @@ package com.itsthevine.web; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; -import net.thebennett.platform.test.PlatformWebContract; - -/** Everything in {@link PlatformWebContract} — what this app must do because it is on the platform. */ +/** + * What this app must do because it is on the platform. + * + *

It used to extend {@code PlatformWebContract} from platform-starter-test and inherit these five + * assertions verbatim. It can't any more, and the reason is worth stating: the shared contract asserts + * that an unknown extension-less path forwards to {@code /index.html}, because it was written when every + * app on the platform was a React SPA. This one is server-rendered now — {@code /index.html} holds + * nothing but the admin shell — so forwarding a mistyped URL there would answer with a blank page and a + * 200 instead of the site's own 404. The contract's own test methods are package-private, so the + * assertion cannot be overridden from here. + * + *

The other four are restated below unchanged, so this app still fails the build on the regression + * the contract exists for (an {@code /api} typo answering with a page and a 200). + * + *

Platform follow-up: {@code PlatformWebContract} should decide which of the two routing + * behaviours to assert by reading {@code platform.web.spa.enabled} — then one contract would cover both + * kinds of app and this file could go back to inheriting it. + */ @SpringBootTest(properties = { // The storage starter activates on its default endpoint, so an S3 client is built even in // tests and fails on blank keys. @@ -19,10 +46,59 @@ import net.thebennett.platform.test.PlatformWebContract; "platform.contact.from=noreply@example.com" }) @Testcontainers -class PlatformContractTest extends PlatformWebContract { +class PlatformContractTest { @Container @ServiceConnection static PostgreSQLContainer postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @Autowired + WebApplicationContext context; + + MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + @DisplayName("an /api path that matches no controller returns 404, not a page") + void unknownApiPathIsNotFound() throws Exception { + mvc.perform(get("/api/a-path-no-controller-serves")).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("a nested unknown /api path returns 404 too") + void unknownNestedApiPathIsNotFound() throws Exception { + mvc.perform(get("/api/deeper/still/not/real")).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("health reports UP") + void healthIsUp() throws Exception { + mvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + + @Test + @DisplayName("liveness and readiness probes are exposed") + void probesAreExposed() throws Exception { + // Docker's HEALTHCHECK and any future orchestrator depend on these existing. + mvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk()); + mvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk()); + } + + @Test + @DisplayName("an unknown route is a 404, and only /admin serves the SPA shell") + void routingIsServerSideExceptForTheAdmin() throws Exception { + mvc.perform(get("/some/client/side/route")).andExpect(status().isNotFound()); + // Assert the forward TARGET, not the body: MockMvc records a forward rather than executing it, + // so the body is empty here by design — which also means this passes before the admin is built. + mvc.perform(get("/admin")) + .andExpect(status().isOk()) + .andExpect(forwardedUrl("/index.html")); + } } diff --git a/src/test/java/com/itsthevine/web/SiteControllerTest.java b/src/test/java/com/itsthevine/web/SiteControllerTest.java new file mode 100644 index 0000000..78d33b2 --- /dev/null +++ b/src/test/java/com/itsthevine/web/SiteControllerTest.java @@ -0,0 +1,142 @@ +package com.itsthevine.web; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * The pages, rendered. + * + *

These assert what a visitor and a crawler are actually served — the catalogue in the HTML rather + * than in a JSON call the page makes later, and a real per-page {@code }. That second one is the + * whole reason {@code PageMetaController} existed; this replaces its test. + * + * <p>They are also the only thing that catches a broken template: a Thymeleaf expression that names a + * model attribute wrongly fails at render time, not at compile time. + */ +@SpringBootTest(properties = { + "platform.storage.access-key=test", + "platform.storage.secret-key=test", + "platform.contact.to=test@example.com", + "platform.contact.from=noreply@example.com", + "site.base-url=https://itsthevine.test", + "site.assets.base-url=https://s3.example.test/itsthevine" +}) +@Testcontainers +class SiteControllerTest { + + @Container + @ServiceConnection + static PostgreSQLContainer<?> postgres = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @Autowired + WebApplicationContext context; + + MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + void everyPageStatesItsOwnTitleAndDescription() throws Exception { + // One generic shell for every page was the SPA's problem, and the reason a controller used to + // rewrite the head with regular expressions. + mvc.perform(get("/")).andExpect(status().isOk()) + .andExpect(content().string(containsString("<title>The Vine Coffeehouse + Bakery"))) + .andExpect(content().string(containsString("A locally owned coffeehouse and bakery"))) + .andExpect(content().string(containsString("og:url\" content=\"https://itsthevine.test\""))); + mvc.perform(get("/products")) + .andExpect(content().string(containsString("Our products · The Vine Coffeehouse + Bakery"))) + .andExpect(content().string(containsString("og:url\" content=\"https://itsthevine.test/products\""))); + mvc.perform(get("/catering")) + .andExpect(content().string(containsString("Goodie boxes & catering · The Vine Coffeehouse + Bakery"))); + mvc.perform(get("/history")) + .andExpect(content().string(containsString("Our story · The Vine Coffeehouse + Bakery"))); + mvc.perform(get("/contact")) + .andExpect(content().string(containsString("Contact us · The Vine Coffeehouse + Bakery"))); + } + + @Test + void theCatalogueIsInTheHtmlRatherThanFetchedAfterwards() throws Exception { + mvc.perform(get("/products")).andExpect(status().isOk()) + // A real product, its category, and a photo URL built from the bucket config. + .andExpect(content().string(containsString("76th Birthday Cake"))) + .andExpect(content().string(containsString("https://s3.example.test/itsthevine/images/"))) + // The filter buttons are links now, so every filtered view is a URL a crawler can follow. + .andExpect(content().string(containsString("href=\"/products?category=Cakes\""))); + } + + @Test + void theCategoryFilterIsAppliedByTheServer() throws Exception { + mvc.perform(get("/products").param("category", "Pie")).andExpect(status().isOk()) + .andExpect(content().string(containsString("Blueberry Cream Pie"))) + .andExpect(content().string(not(containsString("76th Birthday Cake")))) + // The chosen one is marked for a screen reader, not just coloured in. + .andExpect(content().string(containsString("aria-current=\"page\""))); + } + + @Test + void theCateringTablesAreRenderedFromTheDatabase() throws Exception { + mvc.perform(get("/catering")).andExpect(status().isOk()) + .andExpect(content().string(containsString("Office"))) + .andExpect(content().string(containsString("Weddings"))) + // Prices as the server writes them — the page never formats money. + .andExpect(content().string(containsString("$24"))) + .andExpect(content().string(containsString("$236"))) + // A cell, and a note. + .andExpect(content().string(containsString("6+6+6 or 12+6"))) + .andExpect(content().string(containsString("Minimum of 6 items per baked good"))) + // Both renderings of the same table are present; CSS decides which one is visible. + .andExpect(content().string(containsString("alert(1)")) + .andExpect(status().isOk()) + .andExpect(content().string(not(containsString("")))) + .andExpect(content().string(not(containsString("like to ask about")))); + } + + @Test + void anUnknownPageIsNotFound() throws Exception { + // Status only: MockMvc does not run the servlet container's error dispatch, so the body of the + // rendered error/404.html page can't be asserted here. It is checked against a running container + // instead — the page itself is a template like any other, and the layout it uses is covered above. + mvc.perform(get("/no-such-page")).andExpect(status().isNotFound()); + } +}