diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 4b3dc32..d30854e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -40,7 +40,26 @@ jobs: mvn -B -ntp -s .gitea/ci-settings.xml -DskipFrontend=true verify # Build + Trivy on every run (PRs included), so a broken Dockerfile or a new HIGH/CRITICAL CVE - # blocks the merge. Only the push is main-only. + # blocks the merge. + # + # A PULL REQUEST MUST NOT TOUCH THE :latest TAG. This runner builds on the host's Docker daemon — + # the same daemon the live container runs on — so retagging :latest IS a deployment: Watchtower + # compares the running container's image against :latest, finds they differ, and recreates the + # container from the locally built image. Gating only the push was not enough; on 2026-07-26 the PR + # builds for this branch deployed unmerged code to itsthevine.com several times over, and each time + # Watchtower's next pull of the (older) registry :latest reverted it. One of those deploys ran a + # migration against the production database. + # + # So a PR builds pr- instead, which nothing watches. Trivy scans whatever was built, and the + # push step below still only runs off a PR. + - name: Choose the image tag + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "IMAGE_TAG=pr-${{ github.event.number }}" >> "$GITHUB_ENV" + else + echo "IMAGE_TAG=latest" >> "$GITHUB_ENV" + fi + - name: Build image env: DOCKER_BUILDKIT: "1" @@ -51,17 +70,18 @@ jobs: --secret id=maven_user,env=MAVEN_USER \ --secret id=maven_token,env=MAVEN_TOKEN \ --build-arg GIT_SHA=${{ github.sha }} \ - -t git.thebennett.net/thevine/itsthevine:latest -t git.thebennett.net/thevine/itsthevine:${{ github.sha }} . + -t "git.thebennett.net/thevine/itsthevine:$IMAGE_TAG" \ + -t "git.thebennett.net/thevine/itsthevine:${{ github.sha }}" . - name: Scan image (Trivy) run: | docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --no-progress \ - git.thebennett.net/thevine/itsthevine:latest || true + "git.thebennett.net/thevine/itsthevine:$IMAGE_TAG" || true docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \ --pkg-types library --exit-code 1 --no-progress \ - git.thebennett.net/thevine/itsthevine:latest + "git.thebennett.net/thevine/itsthevine:$IMAGE_TAG" # Publish only on a real push to main (or manual dispatch) — never from a pull request. - name: Push image diff --git a/.gitignore b/.gitignore index cf75f71..0f7dad5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ target/ -frontend/node_modules/ -frontend/dist/ +# The Tailwind CLI's dependencies. The compiled stylesheet needs no rule of its own: it is written +# straight into target/classes/static/css, which is already ignored above. +src/main/styles/node_modules/ .idea/ *.iml .vscode/ 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 e327116..3e43021 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,112 @@ # 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 end to end — +there is no JavaScript framework in this repo. 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`. `src/main/styles` is the whole asset pipeline | +| Admin | Thymeleaf forms at `/admin`, behind Authentik | | 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 | +### The look, in one place + +`src/main/styles` is the whole of it — no bundler, no framework, one stylesheet: + +| file | what it holds | +|---|---| +| `theme.css` | the three typefaces and the sage-and-cream palette. Names, nothing drawn | +| `base.css` | bare elements: page background, body type, the fade-in, the focus ring, the z-index scale | +| `type.css` | the typographic ladder (below) | +| `components.css` | `panel`, `pill-*`, `chip`, `section`/`band` — the site's own classes | +| `admin.css` | the admin's controls, as `@utility` so `file:` variants work on the photo pickers | + +**The type ladder comes out of the logo.** The lockup is "The Vine" in LeJour Script over COFFEEHOUSE + +BAKERY in AdBhashitha, letterspaced — and everything below is that idea made progressively more readable: +the script stays in the wordmark and nowhere else; AdBhashitha carries `h1`–`h4` and `price`, keeping the +wordmark's 0.01em letter-spacing so a heading sits on the same rhythm as the logo above it; `eyebrow` and +`label` are the hinge, sans but letterspaced like the tagline; then plain Raleway for anything you have to +read a paragraph of. None of the type classes set a colour — the same heading appears in sage on cream and +cream on sage. + +Write those class names in templates rather than the utilities behind them. The full vocabulary: + +| | | +|---|---| +| surfaces | `panel`, `panel-lift`, `panel-head`, `panel-mark`, `photo` | +| buttons | `pill-sage`, `pill-cream`, `pill-outline`, `pill-ghost`, `chip`/`chip-on` | +| links | `link`, `link-plain`, `link-on-dark` | +| type | `h1`–`h4`, `price`, `eyebrow`, `label`, `lede`, `wordmark` | +| layout | `container`, `measure`, `measure-wide`, `section`, `band`, `page-head` | +| forms | `field` — one input for the public form and the admin | + +Four colourways of one button exist because the site puts buttons on cream *and* on sage. Corners come +from `--radius-panel` (12px) and `--radius-field` (8px), so "how round is a card" is one decision. + +If you find yourself writing `bg-white rounded-… shadow-…` or `underline underline-offset-4` in a +template, there is already a name for it. + +### 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` so a React SPA can own routing, and there is no SPA here — leaving it on would answer a +mistyped URL with a blank page and a 200 instead of the site's own 404. + ## 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`. +- **`/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. +- **`/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 +Forms and redirects. Every write is a POST followed by a redirect back to the page, so the back button +and reload do what they look like they do, a double-tap can't repeat an upload, and there is no +client-side state to lose — a reload is always the truth. Two screens: `/admin` is the catalogue, +`/admin/catering` lists the price tables and `/admin/catering/tables/{id}` edits one. + 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. @@ -39,11 +115,31 @@ Photos are resized, stripped of EXIF, converted to webp and put in the bucket on (`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write webp or ship glibc natives that don't run on Alpine). -**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController` and -`AdminCategoryController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure -Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are -both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get -a bare 401 to handle. +The catering tables are edited a table at a time rather than a field at a time. That isn't a taste in +interfaces: a column heading, its price and the entries beneath it only mean anything together, so +`CateringPackage#arrange` takes the whole table and refuses one whose lines and columns disagree. Drop +the middle column on its own and every remaining entry shifts one place left — the Large box then +advertises the Medium box's contents at the Large price, and nothing about the page looks broken. + +**How that works without JavaScript.** One form holds the whole table and every button in it submits +that form; `name="do"` says which was pressed and its value carries the position it applies to +(`remove-column:2`). So "add a column" arrives with every cell the editor has typed, adds the column to +what arrived — plus an empty entry on every line — and re-renders. Nothing typed is lost, and **only +Save writes**: a half-built table with a blank column heading never reaches the live page, and the +aggregate would refuse it anyway. A failed save comes back the same way, with the work still in the +form and the reason above it, because a redirect would throw the work away and leave the editor guessing +which cell the message was about. + +**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminController` and `AdminCateringController` are +`@ConditionalOnProperty` on it, so a deployment that forgets to configure Authentik gets 404s rather than +catalogue writes open to the internet. `/admin/**` is an authenticated path, so a browser opening it is +sent to Authentik and comes back signed in. There is no JSON admin any more: `AdminProductController`, +`AdminCategoryController` and the old `/api/admin/**` endpoints existed for the React screen and went +with it. Their logic lives in `Catalogue` and `CateringMenu`, which the pages call. + +**Testing a protected page needs `Accept: text/html`.** curl and MockMvc both send `*/*`, which the +platform answers with a bare 401; only a request that prefers HTML gets the 302 to Authentik. Asserting +the 401 and calling the page broken is a mistake worth not making twice. Known gap: `StorageService` has no delete, so removing a product or a photo leaves the object in the bucket. Harmless — nothing links to it — but it accumulates. @@ -57,14 +153,24 @@ 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, admin included (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 +# the stylesheet, while editing templates — watches and recompiles +cd src/main/styles && npm install && npm run watch ``` -Build without the SPA for quick backend loops: `mvn -DskipFrontend=true package`. +`/admin` only exists when `SECURITY_MODE=OIDC`, so a plain local run has the site and no admin. To work +on the admin without an identity provider, run with `SECURITY_MODE=OIDC`, dummy +`spring.security.oauth2.client.*` values (see `AdminPagesTest` for a set that starts without touching +the network) and `platform.security.authenticated-paths=/nothing/**` so nothing asks you to sign in. + +`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): @@ -81,7 +187,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 deleted file mode 100644 index 56edbf0..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,29 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <link rel="icon" media="(prefers-color-scheme: light)" href="/images/resources/logo_L.png"> - <link rel="icon" media="(prefers-color-scheme: dark)" href="/images/resources/logo_dark.png"> - <!-- Preconnect to the photo bucket so product images start loading a round-trip sooner. --> - <link rel="preconnect" href="https://s3.thebennett.net" crossorigin> - <!-- 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 - - - - - - - - - -
- - - diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index 5e0a68d..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,2853 +0,0 @@ -{ - "name": "itsthevine-frontend", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "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" - }, - "devDependencies": { - "@tailwindcss/vite": "4.3.3", - "@types/node": "^26.1.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "tailwindcss": "4.3.3", - "typescript": "^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" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "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", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "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": "*" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", - "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.24.1", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", - "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", - "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", - "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", - "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", - "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", - "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", - "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", - "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", - "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", - "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", - "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", - "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", - "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "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", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", - "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "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==", - "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" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "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", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "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", - "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "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", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "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==", - "license": "MIT", - "dependencies": { - "motion-dom": "^12.42.2", - "motion-utils": "^12.39.0", - "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/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "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", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "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", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "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", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@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==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "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==", - "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" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "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", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "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", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "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", - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "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" - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "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", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "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 deleted file mode 100644 index fb9bde5..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "itsthevine-frontend", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc --noEmit && vite build", - "preview": "vite preview" - }, - "dependencies": { - "motion": "12.42.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-router-dom": "7.18.1" - }, - "devDependencies": { - "@tailwindcss/vite": "4.3.3", - "@types/node": "^26.1.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "tailwindcss": "4.3.3", - "typescript": "^7.0.0", - "vite": "^8.1.3", - "vite-plugin-svgr": "^5.0.0" - } -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 0e84f3d..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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. - */ -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 ( -
-
-
- {/* Logo */} -
- -
- - {/* Navigation Links */} -
-

Navigation

-
    -
  • - Our Products -
  • -
  • - Our Story -
  • -
  • - Contact -
  • -
-
- - {/* Contact Info */} -
-

Visit

-
-

215 E Main Street
Princeville, IL 61559

-

(309) 701-0660

-

- contact@itsthevine.com -

-
-
-
- - {/* Bottom Bar */} -
-

© {new Date().getFullYear()} The Vine Coffeehouse + Bakery

-
-
-
- ); -}; - -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/fonts/AdBhashitha.woff b/frontend/src/fonts/AdBhashitha.woff deleted file mode 100644 index 1a5e01c..0000000 Binary files a/frontend/src/fonts/AdBhashitha.woff and /dev/null differ diff --git a/frontend/src/fonts/LeJour-Script.woff b/frontend/src/fonts/LeJour-Script.woff deleted file mode 100644 index 98f4e4f..0000000 Binary files a/frontend/src/fonts/LeJour-Script.woff and /dev/null differ diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index 8994160..0000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,85 +0,0 @@ -@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); -} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts deleted file mode 100644 index 4fae6d0..0000000 --- a/frontend/src/lib/api.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * The catalogue, its ordering, its category filter and its image URLs are all decided by the - * backend — this file just fetches them. Same origin, so no base URL and no CORS. - */ - -export interface Product { - id: number; - name: string; - category: string; - /** Absolute, ready to put in a src. Built server-side from the bucket config. */ - images: string[]; -} - -/** What the admin screens get back: the public shape plus where it sits in the order. */ -export interface AdminProduct extends Product { - position: number; - /** The same photos as `images`, in the same order — these are what arrangePhotos names them by. */ - keys: string[]; -} - -export interface AdminCategory { - id: number; - name: string; - position: number; - /** How many products are filed under it — deleting one that's in use is refused. */ - used: number; -} - -async function get(path: string): Promise { - const res = await fetch(path, { headers: { Accept: 'application/json' } }); - if (!res.ok) throw new Error(`${path} responded ${res.status}`); - return res.json() as Promise; -} - -export const fetchProducts = (category?: string) => - get(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products'); - -export const fetchCategories = () => get('/api/categories'); - -/** - * Spring hands the SPA a CSRF token in a cookie and wants it echoed on anything that writes. Read - * per request rather than cached: it rotates on sign-in, and a stale token fails exactly like a - * missing one. Returns nothing when security is off, which is why the contact form still posts - * happily on a deployment with no identity provider. - */ -export function csrfHeader(): Record { - const token = document.cookie - .split('; ') - .find((c) => c.startsWith('XSRF-TOKEN=')) - ?.slice('XSRF-TOKEN='.length); - return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {}; -} - -/** - * Every admin write funnels through here so one place understands the server's failure shapes: a - * 401/403 means the session lapsed (the OIDC chain answers /api with a status rather than bouncing - * you to a login page), and anything else carries a ProblemDetail whose `detail` is the sentence - * the server wants the editor to read. - */ -async function send(path: string, method: string, body?: unknown, form?: FormData): Promise { - const res = await fetch(path, { - method, - headers: { - Accept: 'application/json', - ...(form ? {} : { 'Content-Type': 'application/json' }), - ...csrfHeader(), - }, - body: form ?? (body === undefined ? undefined : JSON.stringify(body)), - }); - - if (res.status === 401 || res.status === 403) { - throw new Error('Your sign-in has expired — refresh the page to sign in again.'); - } - if (!res.ok) { - const problem = await res.json().catch(() => null); - throw new Error(problem?.detail || problem?.error || 'That did not save. Please try again.'); - } - return (res.status === 204 ? undefined : await res.json()) as T; -} - -// --- products --------------------------------------------------------------- - -export const adminProducts = () => get('/api/admin/products'); - -export function createProduct(name: string, category: string, photos: File[]) { - const form = new FormData(); - form.append('name', name); - form.append('category', category); - photos.forEach((p) => form.append('photos', p)); - return send('/api/admin/products', 'POST', undefined, form); -} - -export const describeProduct = (id: number, name: string, category: string) => - send(`/api/admin/products/${id}`, 'PUT', { name, category }); - -export const deleteProduct = (id: number) => - send<{ ok: boolean }>(`/api/admin/products/${id}`, 'DELETE'); - -export function addPhotos(id: number, photos: File[]) { - const form = new FormData(); - photos.forEach((p) => form.append('photos', p)); - return send(`/api/admin/products/${id}/photos`, 'POST', undefined, form); -} - -/** The full arrangement the editor is looking at — removing a photo is just an omission. */ -export const arrangePhotos = (id: number, keys: string[]) => - send(`/api/admin/products/${id}/photos`, 'PUT', keys); - -export const reorderProducts = (ids: number[]) => - send('/api/admin/products/order', 'PUT', { ids }); - -// --- categories ------------------------------------------------------------- - -export const adminCategories = () => get('/api/admin/categories'); - -export const createCategory = (name: string) => - send('/api/admin/categories', 'POST', { name }); - -export const renameCategory = (id: number, name: string) => - send(`/api/admin/categories/${id}`, 'PUT', { name }); - -export const reorderCategories = (ids: number[]) => - send('/api/admin/categories/order', 'PUT', { ids }); - -export const deleteCategory = (id: number) => - send<{ ok: boolean }>(`/api/admin/categories/${id}`, 'DELETE'); 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 deleted file mode 100644 index e2d123a..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import App from './App'; -import './index.css'; - -createRoot(document.getElementById('root')!).render( - - - - - , -); diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx deleted file mode 100644 index bdad42c..0000000 --- a/frontend/src/pages/Admin.tsx +++ /dev/null @@ -1,716 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { - addPhotos, - adminCategories, - adminProducts, - arrangePhotos, - createCategory, - createProduct, - deleteCategory, - deleteProduct, - describeProduct, - renameCategory, - reorderCategories, - reorderProducts, - type AdminCategory, - type AdminProduct, -} from '@/lib/api'; - -/** - * The catalogue, editable by the person who bakes it. - * - * The whole screen is built around one rule: nothing waits on the network to look like it happened. - * Reordering and deleting apply to the list on screen first and reconcile afterwards, because an - * editor tidying twenty items shouldn't be typing into a page that freezes between every click. - * Uploads are the exception — they genuinely take a moment (resize, convert, send), so they say so. - * - * Getting here at all means signing in: /admin is an authenticated path, so an unknown visitor is - * sent to the identity provider before this ever loads. - */ - -// --- little pieces ---------------------------------------------------------- - -const Icon = ({ d, className = '' }: { d: string; className?: string }) => ( - -); - -const ARROW_UP = 'M12 19V5M5 12l7-7 7 7'; -const ARROW_DOWN = 'M12 5v14M19 12l-7 7-7-7'; -const ARROW_LEFT = 'M19 12H5M12 19l-7-7 7-7'; -const ARROW_RIGHT = 'M5 12h14M12 5l7 7-7 7'; -const TRASH = 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6'; -const PLUS = 'M12 5v14M5 12h14'; -const CHECK = 'M20 6L9 17l-5-5'; -const X = 'M18 6L6 18M6 6l12 12'; - -const button = - 'inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium ' + - 'transition-colors disabled:opacity-40 disabled:cursor-not-allowed'; -const primary = `${button} bg-bakery-600 text-white hover:bg-bakery-700`; -const secondary = `${button} border border-bakery-300 text-bakery-800 hover:bg-bakery-100`; -const danger = `${button} text-red-700 hover:bg-red-50`; -const iconButton = - 'inline-flex items-center justify-center w-7 h-7 rounded-md border border-bakery-300 ' + - 'text-bakery-700 hover:bg-bakery-100 transition-colors disabled:opacity-30 disabled:cursor-not-allowed'; -const field = - 'w-full rounded-md border border-bakery-300 bg-white px-3 py-2 text-sm ' + - 'focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500'; - -/** Moves one entry of a list by `delta`, or returns the list untouched if that would fall off an end. */ -function shift(items: T[], index: number, delta: number): T[] { - const target = index + delta; - if (target < 0 || target >= items.length) return items; - const next = [...items]; - [next[index], next[target]] = [next[target], next[index]]; - return next; -} - -// --- photos ----------------------------------------------------------------- - -/** - * The photos on one item. Order matters — the first is the one the products page leads with — so - * arranging is left/right rather than a drag target, which is far easier to hit on a phone. - */ -const Photos = ({ - product, - onArrange, - busy, -}: { - product: AdminProduct; - onArrange: (keys: string[]) => void; - busy: boolean; -}) => ( -
- {product.images.map((url, i) => ( -
- -
-
- - -
- -
-
- ))} -
-); - -/** A file picker styled as a button, resetting itself so the same file can be chosen twice. */ -const PhotoPicker = ({ - label, - onPick, - disabled, - className = secondary, -}: { - label: string; - onPick: (files: File[]) => void; - disabled?: boolean; - className?: string; -}) => { - const input = useRef(null); - return ( - <> - { - const files = Array.from(e.target.files ?? []); - e.target.value = ''; - if (files.length) onPick(files); - }} - /> - - - ); -}; - -// --- one item --------------------------------------------------------------- - -const ProductCard = ({ - product, - categories, - first, - last, - onChange, - onMove, - onDelete, - onError, -}: { - product: AdminProduct; - categories: string[]; - first: boolean; - last: boolean; - onChange: (updated: AdminProduct) => void; - onMove: (delta: number) => void; - onDelete: () => void; - onError: (message: string) => void; -}) => { - const [name, setName] = useState(product.name); - const [category, setCategory] = useState(product.category); - const [busy, setBusy] = useState(false); - - // A reorder or an upload re-fetches this product; the fields should follow unless they're being - // edited, which is what the dirty check below decides. - const dirty = name !== product.name || category !== product.category; - const [synced, setSynced] = useState(product); - if (synced !== product) { - setSynced(product); - if (!dirty) { - setName(product.name); - setCategory(product.category); - } - } - - const run = async (work: () => Promise) => { - setBusy(true); - try { - onChange(await work()); - } catch (e) { - onError(e instanceof Error ? e.message : 'That did not save.'); - } finally { - setBusy(false); - } - }; - - return ( -
  • -
    -
    - - -
    - -
    -
    - - -
    - - run(() => arrangePhotos(product.id, keys))} - /> - -
    - run(() => addPhotos(product.id, files))} - /> - {dirty && ( - <> - - - - )} - -
    - {busy &&

    Working…

    } -
    -
    -
  • - ); -}; - -// --- adding an item --------------------------------------------------------- - -const NewItem = ({ - categories, - onAdded, - onError, -}: { - categories: string[]; - onAdded: (product: AdminProduct) => void; - onError: (message: string) => void; -}) => { - const [name, setName] = useState(''); - const [category, setCategory] = useState(categories[0] ?? ''); - const [files, setFiles] = useState([]); - const [busy, setBusy] = useState(false); - - // The category list arrives after the first render, so the default has to catch up once. - useEffect(() => { - setCategory((c) => (c || categories[0] || '')); - }, [categories]); - - const submit = async () => { - setBusy(true); - try { - onAdded(await createProduct(name.trim(), category, files)); - setName(''); - setFiles([]); - } catch (e) { - onError(e instanceof Error ? e.message : 'That did not save.'); - } finally { - setBusy(false); - } - }; - - return ( -
    -

    Add something new

    -

    New items go to the top of the products page.

    - -
    - setName(e.target.value)} - placeholder="What is it? e.g. Chocolate drip cake" - /> - -
    - - {files.length > 0 && ( -
    - {files.map((file, i) => ( -
    - URL.revokeObjectURL(e.currentTarget.src)} - /> - -
    - ))} -
    - )} - -
    - setFiles((current) => [...current, ...picked])} - /> - - {busy && Photos can take a few seconds each.} -
    -
    - ); -}; - -// --- categories ------------------------------------------------------------- - -const Categories = ({ - categories, - setCategories, - onError, - onChanged, -}: { - categories: AdminCategory[]; - setCategories: (next: AdminCategory[]) => void; - onError: (message: string) => void; - onChanged: () => void; -}) => { - const [fresh, setFresh] = useState(''); - const [editing, setEditing] = useState(null); - const [draft, setDraft] = useState(''); - const [busy, setBusy] = useState(false); - - const guard = async (work: () => Promise, optimistic?: AdminCategory[]) => { - const before = categories; - if (optimistic) setCategories(optimistic); - setBusy(true); - try { - await work(); - onChanged(); - } catch (e) { - if (optimistic) setCategories(before); - onError(e instanceof Error ? e.message : 'That did not save.'); - } finally { - setBusy(false); - } - }; - - return ( -
    -

    Categories

    -

    - These are the filter buttons on the products page, in this order. Renaming one moves - everything filed under it too. -

    - -
      - {categories.map((category, i) => ( -
    • -
      - - -
      - - {editing === category.id ? ( - <> - setDraft(e.target.value)} - onKeyDown={(e) => e.key === 'Escape' && setEditing(null)} - /> - - - - ) : ( - <> - {category.name} - - {category.used} item{category.used === 1 ? '' : 's'} - - - - - )} -
    • - ))} -
    - -
    - setFresh(e.target.value)} - placeholder="New category" - onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && guard(async () => { - await createCategory(fresh.trim()); - setFresh(''); - })} - /> - -
    -
    - ); -}; - -// --- the page --------------------------------------------------------------- - -const AdminPage = () => { - const [products, setProducts] = useState(null); - const [categories, setCategories] = useState([]); - const [error, setError] = useState(''); - - const load = useCallback(async () => { - try { - const [items, cats] = await Promise.all([adminProducts(), adminCategories()]); - setProducts(items); - setCategories(cats); - } catch (e) { - setError(e instanceof Error ? e.message : 'Could not load the catalogue.'); - setProducts([]); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - const names = categories.map((c) => c.name); - - /** Reorder and delete both apply on screen first — the point of this page is that it keeps up. */ - const settle = async (optimistic: AdminProduct[], work: () => Promise) => { - const before = products ?? []; - setProducts(optimistic); - try { - await work(); - } catch (e) { - setProducts(before); - setError(e instanceof Error ? e.message : 'That did not save.'); - } - }; - - return ( -
    -
    -
    -

    The Vine

    -

    Everything on the products page lives here.

    -
    -
    - - View the page - - {/* A real form post: the platform's logout expects one, and it also ends the Authentik session. */} -
    - -
    -
    -
    - - {error && ( -
    - {error} - -
    - )} - - {products === null ? ( -

    Loading…

    - ) : ( -
    - void load()} - /> - - setProducts([product, ...products])} - /> - -
    -

    - On the page ({products.length}) -

    -
      - {products.map((product, i) => ( - - setProducts(products.map((p) => (p.id === updated.id ? updated : p))) - } - onMove={(delta) => { - const moved = shift(products, i, delta); - void settle(moved, () => reorderProducts(moved.map((p) => p.id))); - }} - onDelete={() => { - if (!confirm(`Remove ${product.name} from the products page?`)) return; - void settle( - products.filter((p) => p.id !== product.id), - () => deleteProduct(product.id), - ); - }} - /> - ))} -
    - {products.length === 0 && ( -

    Nothing here yet — add something above.

    - )} -
    -
    - )} -
    - ); -}; - -export default AdminPage; 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 -

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

    Clearing a box and saving removes that note.

    + + +
    + + diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html new file mode 100644 index 0000000..e0fa461 --- /dev/null +++ b/src/main/resources/templates/admin/layout.html @@ -0,0 +1,58 @@ + + + + + + + + + The Vine — admin + + + +
    +
    + +
    +
    +

    The Vine

    + +
    +
    + View the site + +
    + +
    +
    +
    + + + +
    Saved.
    + +
    +
    +
    +
    +
    + + diff --git a/src/main/resources/templates/admin/table.html b/src/main/resources/templates/admin/table.html new file mode 100644 index 0000000..c538a03 --- /dev/null +++ b/src/main/resources/templates/admin/table.html @@ -0,0 +1,124 @@ + + + +
    + +
    + +
    +
    +

    This table

    + All tables +
    + +
    + + +
    + + +
    + + + + + + + + + + + + + + + +
    What they get + + + + + +
    + + + +
    +
    + +
    + + + + + +
    + + + +
    +
    +
    + + + +
    +

    + Small print under this table — minimums, what can't be mixed, how delivery is charged. +

    +
    +
    + + +
    +
    + +
    + +
    + + Start again + + Not saved yet — press Save when the table looks right. + +
    +
    +
    +
    + + diff --git a/src/main/resources/templates/catering.html b/src/main/resources/templates/catering.html new file mode 100644 index 0000000..9589318 --- /dev/null +++ b/src/main/resources/templates/catering.html @@ -0,0 +1,146 @@ + + + +
    + +
    + +
    +
    + +

    + Every one of these is a starting point. Tell us the date, the number of people and what you had in + mind, and we will bake to it. +

    + +
    + +
    + + +
    +
    + + + +
    +
    + + + +
    +

    Small

    +

    $24

    + +

    Ask us

    + +

    About 6–8 people

    +
    + +
      +
    • + + + + Mini muffins + + — a dozen + +
    • +
    + +
    +
    + + +
    +
    +

    Good to know

    +
      +
    • Everything is baked in sixes.
    • +
    +
    + Ask about office boxes +
    +
    + + +

    + Our catering list is being updated. Call us on + (309) 701-0660 and we will + talk it through. +

    +
    +
    +
    + + +
    +
    +
    +

    Before you order

    +
      +
    • These are a guide rather than a menu.
    • +
    + +
    +
    +
    + +
    + + diff --git a/src/main/resources/templates/contact.html b/src/main/resources/templates/contact.html new file mode 100644 index 0000000..bb79a3d --- /dev/null +++ b/src/main/resources/templates/contact.html @@ -0,0 +1,106 @@ + + + +
    + +
    + + +
    +
    +
    +

    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..de96225 --- /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..7e54cb7 --- /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..34f7f03 --- /dev/null +++ b/src/main/resources/templates/fragments/footer.html @@ -0,0 +1,37 @@ + + + + + + diff --git a/src/main/resources/templates/fragments/head.html b/src/main/resources/templates/fragments/head.html new file mode 100644 index 0000000..e8c5924 --- /dev/null +++ b/src/main/resources/templates/fragments/head.html @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + 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..e55e2a0 --- /dev/null +++ b/src/main/resources/templates/fragments/header.html @@ -0,0 +1,77 @@ + + + + +
    +
    +
    +
    + + + + +
    + + + + +
    + +
    +
    +
    +
    +
    + + + + Our Products + + + diff --git a/src/main/resources/templates/fragments/lockup.html b/src/main/resources/templates/fragments/lockup.html new file mode 100644 index 0000000..7d006fb --- /dev/null +++ b/src/main/resources/templates/fragments/lockup.html @@ -0,0 +1,46 @@ + + + + + + + + + The Vine + Coffeehouse + Bakery + + + + + + + + + + +
    + +
    + + diff --git a/src/main/resources/templates/fragments/opening.html b/src/main/resources/templates/fragments/opening.html new file mode 100644 index 0000000..c3ba8fb --- /dev/null +++ b/src/main/resources/templates/fragments/opening.html @@ -0,0 +1,62 @@ + + + + + +
    + + +
    +
    + +
    +
    +

    Order ahead

    +

    Goodie boxes & catering

    +

    One line about the page.

    +
    +
    +
    + +
    +

    From the counter

    +

    Our products

    +

    One line about the page.

    +
    + + +
    +

    What people come in for

    +

    A line.

    +
    + + +
    +

    Before you order

    +

    A line.

    +
    + + + + + diff --git a/src/main/resources/templates/fragments/page.html b/src/main/resources/templates/fragments/page.html new file mode 100644 index 0000000..28cb33b --- /dev/null +++ b/src/main/resources/templates/fragments/page.html @@ -0,0 +1,31 @@ + + + + + +
    + + +
    +
    +
    +
    +
    +
    + + + + diff --git a/src/main/resources/templates/fragments/visit.html b/src/main/resources/templates/fragments/visit.html new file mode 100644 index 0000000..48965c8 --- /dev/null +++ b/src/main/resources/templates/fragments/visit.html @@ -0,0 +1,44 @@ + + + + + +
      +
    • + Tuesday – Friday + 7:00am – 2:00pm +
    • +
    + +
    +

    215 E Main Street

    +

    Princeville, IL 61559

    +

    + (309) 701-0660 +

    +

    + contact@itsthevine.com +

    +
    + + + + + + Open until 2:00pm + + + diff --git a/src/main/resources/templates/history.html b/src/main/resources/templates/history.html new file mode 100644 index 0000000..388b69d --- /dev/null +++ b/src/main/resources/templates/history.html @@ -0,0 +1,95 @@ + + + +
    + +
    + +
    +
    +
    +

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

    +
    + +
    + Cinnamon rolls, iced, on the counter at The Vine +
    The cinnamon rolls, most mornings
    +
    + +
    +

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

    +
    + +
    + A decorated cake made to order at The Vine +
    Made to order, whatever the week is celebrating
    +
    + +
    +

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

    +
    +
    +
    + + +
    +
    +
    +

    Come and see us

    +

    + We open at 7:00am, Tuesday through Saturday. If you are after something for a date in the + diary, it is worth asking early. +

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

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

    + +
    +
    +
    + + +
    +
    +
    +
    +
    + + +

    Cinnamon Rolls

    +
    +
    +
    +
    + + +
    +
    +
    + Iced sugar cookies from The Vine +
    +

    Ordering for a crowd?

    +

    + Goodie boxes for the office, packages for a party, and cakes and desserts for a wedding — + with prices, sizes and what each one includes, so you can see where to start. +

    + + Goodie boxes & catering + +
    +
    +
    +
    + + +
    +
    +
    +

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

    Our hours

    +
    +
    +
    +

    Find us

    +
    +
    +
    +
    +
    + +
    + + diff --git a/src/main/resources/templates/products.html b/src/main/resources/templates/products.html new file mode 100644 index 0000000..184f497 --- /dev/null +++ b/src/main/resources/templates/products.html @@ -0,0 +1,94 @@ + + + +
    + +
    + +
    + +
    + All +
    + + +
    +
    +
    +
    +
    + +
    +

    Name

    + Category +
    +
    +
    + +

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

    +
    + + +
    +
    +
    +

    Want one of these, or something else?

    +

    + Cakes and decorated cookies are made to order. Tell us the date and roughly what you have in + mind — a tractor, a retirement, the class of 1964 — and we will work from there. +

    + +
    +
    +
    +
    + + +
    + +
    + + diff --git a/src/main/styles/admin.css b/src/main/styles/admin.css new file mode 100644 index 0000000..07ef935 --- /dev/null +++ b/src/main/styles/admin.css @@ -0,0 +1,47 @@ +/* + * The admin's controls. + * + * Component classes rather than utility strings repeated through the markup, because the admin is made + * of forms: there are dozens of buttons on a page and each one is its own
    . The public pages keep + * their utilities inline — they each look different — but "a button in the admin" should be one decision + * in one place. These are the same buttons the React screen had, from the same class strings it composed. + * + * @utility, not `.btn { @apply … }` in a components layer: Tailwind v4 will only let you @apply a class + * it knows as a utility, and only a registered utility can take a variant — which `file:btn-secondary` + * on the photo pickers needs. + */ +@utility btn { + @apply inline-flex items-center justify-center gap-1.5 rounded-field px-3 py-1.5 text-sm font-medium + transition-colors; +} + +@utility btn-primary { + @apply btn bg-bakery-600 text-white hover:bg-bakery-700 + disabled:opacity-40 disabled:cursor-not-allowed; +} + +@utility btn-secondary { + @apply btn border border-bakery-300 text-bakery-800 hover:bg-bakery-100 + disabled:opacity-40 disabled:cursor-not-allowed; +} + +@utility btn-danger { + @apply btn text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed; +} + +/* Square, for the arrows and the crosses — a row of these is how anything gets reordered. */ +@utility btn-icon { + @apply inline-flex items-center justify-center w-7 h-7 rounded-field border border-bakery-300 + text-bakery-700 hover:bg-bakery-100 transition-colors + disabled:opacity-30 disabled:cursor-not-allowed; +} + +/* `field` is shared with the public form — see components.css. */ + +@utility card { + @apply rounded-panel border border-bakery-200 bg-white p-4 shadow-sm; +} + +@utility card-heading { + @apply font-adbhashitha text-xl text-bakery-800; +} diff --git a/src/main/styles/base.css b/src/main/styles/base.css new file mode 100644 index 0000000..dc3ddbb --- /dev/null +++ b/src/main/styles/base.css @@ -0,0 +1,133 @@ +/* + * Element defaults: the page background, the body type, the fade-in, and the two small resets the site + * needs. Anything with a class name belongs in components.css instead. + */ + +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); + } +} + +::selection { + background-color: var(--color-bakery-300); + color: var(--color-bakery-900); +} + +/* The mobile menu is a
    ; its default disclosure triangle would sit beside the hamburger. Lost + once already in a refactor, which is exactly the kind of thing nobody notices until a phone. */ +summary { + list-style: none; +} +summary::-webkit-details-marker { + display: none; +} + +/* + * FOCUS. One visible ring for everything you can reach with a keyboard, as an outline rather than a + * shadow so it shows up on cream, on white and on the sage bands without needing three variants. + * :where() keeps its specificity at zero, so a utility can still override it. + */ +:where(a, button, summary, input, select, textarea, [tabindex]):focus-visible { + outline: 2px solid var(--color-bakery-500); + outline-offset: 3px; + border-radius: 3px; +} + +/* + * On the sage bands that same sage is about 2.5:1 against the background — under the 3:1 a focus + * indicator needs, so the ring effectively disappeared exactly where the page's main calls to action + * live. Cream on those sections. + */ +:where(.bg-bakery-800, .bg-bakery-900) :where(a, button, summary, input, select, textarea, [tabindex]):focus-visible { + outline-color: var(--color-bakery-100); +} + +/* + * THE Z-INDEX SCALE, in full. Two layers is all this site has, and writing them down is what stops a + * third being invented at 9999: + * + * 40 the sticky header + * 30 the mobile menu panel, which is a child of the header and therefore already inside its stacking + * context — it can only ever sit under the bar, which is exactly where it belongs + * + * Everything else stacks in document order: the hero's tint sits over its photo because it comes after + * it, and the product-card arrows sit over the photos for the same reason. + */ + +/* + * A native select still gets its own arrow from the platform, in the platform's colour. This is the one + * control the admin uses that we don't draw ourselves, so it gets the site's chevron instead. + */ +select.field { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234a5740' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.6rem center; + background-size: 1.1rem; + padding-right: 2.4rem; +} + +/* + * PRINT. The catering page is a price list, and a bakery prints price lists — so what comes out of a + * printer should be the prices, not a screenshot of a website. The chrome goes, the sage bands go (they + * would eat a cartridge), the cards keep their edges so the columns stay readable, and links stop being + * blue-and-underlined because paper has no links. + */ +@media print { + header, + footer, + .no-print { + display: none !important; + } + + body { + background: #fff; + color: #000; + opacity: 1; + animation: none; + } + + /* Sage-on-white blocks become plain text: the words matter, the ink does not. */ + [class*="bg-bakery-8"], + [class*="bg-bakery-9"] { + background: #fff !important; + color: #000 !important; + } + + a { + text-decoration: none; + color: inherit; + } + + /* A card should not be split across a page break mid-price. */ + article, + section { + break-inside: avoid; + } +} diff --git a/src/main/styles/components.css b/src/main/styles/components.css new file mode 100644 index 0000000..46c7483 --- /dev/null +++ b/src/main/styles/components.css @@ -0,0 +1,276 @@ +/* + * The public site's own classes — the ones that would be unreadable as a string of utilities in every + * template that needs them. The admin's controls are in admin.css; the type ladder is in type.css. + * + * Almost everything here is @utility rather than a plain class in a layer, for two reasons Tailwind v4 + * enforces: only a registered utility can be @applied by another rule (panel-mark builds on mark), and + * only a registered utility can take a variant (`hover:`, `md:`, `file:`). + */ + +/* + * The page gutter, and the only plain class left in a layer. + * + * Every section used to write `container mx-auto px-4`, carried over from the React site: `mx-auto` twice + * and a px-4 that beat the responsive padding below, so the gutter was 16px at every width including a + * 27-inch screen. The markup now says `container` and nothing else, which is why this can stay in the + * components layer — there is no longer a utility next to it that has to win. + */ +@layer components { + .container { + @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; + } +} + +/* + * MEASURES. Text you read wants a narrow column and a grid of cards wants a wide one; those two numbers + * were seven different max-w-* classes chosen a template at a time. + */ +@utility measure { + @apply max-w-2xl mx-auto; +} + +@utility measure-wide { + @apply max-w-5xl mx-auto; +} + +/* + * LINKS in running text. Underlined, offset so the line clears the descenders, and colour-shifting on + * hover — three variants because a link in a paragraph, a link on a sage band and a quiet one in small + * print are the same idea in different rooms. + */ +@utility link { + @apply underline underline-offset-4 text-bakery-700 hover:text-bakery-900 transition-colors; +} + +@utility link-on-dark { + @apply underline underline-offset-4 text-bakery-50 hover:text-white transition-colors; +} + +/* Inherits its colour: for a link inside a coloured paragraph that already sets one. */ +@utility link-plain { + @apply underline underline-offset-4 hover:text-bakery-600 transition-colors; +} + +/* + * FIELDS. One input, everywhere. The public form had its own (cream, ring-2, py-2.5) and the admin had + * another (white, ring-1, py-2, text-sm), which is what happens when a form gets written twice. + */ +/* + * text-base on a phone, not text-sm: Safari zooms the whole page in when you focus an input smaller than + * 16px, which on the contact form means every enquiry typed on a phone starts by throwing the layout + * sideways. The denser size comes back at sm, where no browser does that. + */ +@utility field { + @apply w-full rounded-field border border-bakery-300 bg-white px-3.5 py-2.5 text-base sm:text-sm + focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500; +} + +/* A photograph standing on the page rather than inside a card: same edge and shadow as one. */ +@utility photo { + @apply rounded-panel border border-bakery-200/70; + box-shadow: var(--shadow-card); +} + +/* The cream header at the top of a page that has no photographic band. */ +@utility page-head { + @apply container pt-14 pb-10 md:pt-20 md:pb-12 text-center; +} + +/* + * THE BRANCH MARKS of the wordmark, as masks rather than inline SVG. + * + * They have to take their colour from the surrounding text — sage in the header, cream on the hero and in + * the footer — which an cannot do. React solved that by inlining them through svgr, but these files + * are 31 KB each and the lockup appears up to three times on a page: inlining them in server-rendered + * HTML would add ~190 KB to every response. A mask over `currentColor` keeps the tinting, keeps the files + * cacheable, and costs two requests once. + */ +@utility mark { + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; +} + +@utility mark-r { + -webkit-mask-image: url('/images/logo_R.svg'); + mask-image: url('/images/logo_R.svg'); +} + +@utility mark-l { + -webkit-mask-image: url('/images/logo_L.svg'); + mask-image: url('/images/logo_L.svg'); +} + +/* + * The photo strip on a product card scrolls, but a scrollbar across the bottom of a photo is not part of + * the design. Hiding it is safe here because the strip is not the only way through the photos: it snaps, + * it takes arrow keys, and the arrows and dots are on top of it. + */ +@utility no-scrollbar { + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +/* + * SURFACES. + * + * These were plain white boxes with a grey drop shadow — the default card of every web app, on a page + * that is otherwise cream, sage and a hand-drawn vine. Three things make them the shop's: + * + * a warm hairline, so a card has an EDGE and reads as stock rather than as a floating panel; + * a shadow tinted with the darkest sage instead of black (see theme.css); + * a head that can be tinted cream, the way a printed menu separates the price from what you get. + * + * `panel-lift` is one you can click, and it rises rather than just darkening. + */ +@utility panel { + @apply bg-white rounded-panel border border-bakery-200/70; + box-shadow: var(--shadow-card); +} + +@utility panel-lift { + @apply panel transition duration-300; + &:hover { + box-shadow: var(--shadow-card-lift); + transform: translateY(-2px); + } +} + +/* The cream band on a card: a size and its price, or a photo's caption. */ +@utility panel-head { + @apply bg-bakery-50/70 border-b border-bakery-200/70; +} + +/* + * A card that is holding an aside rather than the thing you came for: the small print under a price + * table, a pointer to another page. Tinted and bordered, no shadow, so it sits ON the page instead of + * above it and does not compete with the cards it follows. There were two hand-rolled versions of this + * before it had a name, and they had different borders. + */ +@utility panel-quiet { + @apply rounded-panel border border-bakery-200/70 bg-bakery-50/60; +} + +/* + * A grid of cards. It was written three times with three different sets of breakpoints and gaps — the + * homepage went to three columns at md, the catering tables at lg, the catalogue at lg with a wider gap — + * so cards on different pages lined up differently for no reason anyone chose. + */ +@utility card-grid { + @apply grid gap-6 md:gap-8 sm:grid-cols-2 lg:grid-cols-3; +} + +/* + * The branch, laid into a corner at the weight of a watermark. It is the one mark that is unmistakably + * this shop, and at this size and colour it is texture rather than decoration. + */ +@utility panel-mark { + @apply mark mark-l absolute pointer-events-none text-bakery-200/70; +} + +/* + * BUTTONS. One shape — a full pill, generous, letterspaced — in four colourways, because the site puts + * buttons on cream and on sage and they cannot be the same colour on both. + * + * pill-sage filled, on a light background pill-outline quiet, on a light background + * pill-cream filled, on a sage background pill-ghost quiet, on a sage background + */ +@utility pill { + @apply inline-flex items-center justify-center gap-2 rounded-full + px-8 py-3.5 font-medium tracking-wide transition-colors; +} + +@utility pill-sage { + @apply pill bg-bakery-600 text-white hover:bg-bakery-700; +} + +@utility pill-cream { + @apply pill bg-bakery-50 text-bakery-900 hover:bg-white shadow-lg; +} + +@utility pill-outline { + @apply pill border border-bakery-300 text-bakery-800 hover:bg-bakery-100; +} + +@utility pill-ghost { + @apply pill border border-bakery-200/60 text-bakery-50 hover:bg-bakery-50/10; +} + +/* + * Smaller than a pill, for a filter row rather than a call to action — but still 44px tall, which is the + * smallest thing a thumb should be asked to hit. + */ +@utility chip { + @apply inline-flex items-center justify-center rounded-full border px-5 sm:px-6 min-h-11 label + whitespace-nowrap bg-white border-bakery-300 text-bakery-700 transition-colors hover:bg-bakery-100; +} + +@utility chip-on { + @apply chip bg-bakery-600 border-bakery-600 text-white hover:bg-bakery-600; +} + +/* + * RHYTHM. Two vertical sizes, so the page has a beat instead of nine hand-picked paddings: `section` for + * a block of content, `band` for a photographic or coloured strip, which sits tighter because its + * background is already doing the separating. + */ +@utility section { + @apply py-16 md:py-24; +} + +@utility band { + @apply py-14 md:py-20; +} + +/* + * A row of filters that scrolls sideways on a phone instead of wrapping onto three lines with one item + * stranded on the last. It wraps and centres from sm, where there is room. The negative margin lets the + * strip bleed to the screen edge so a half-visible chip shows there is more. + */ +@utility filter-row { + @apply flex gap-3 overflow-x-auto no-scrollbar snap-x -mx-4 px-4 + sm:flex-wrap sm:justify-center sm:gap-4 sm:overflow-visible sm:mx-0 sm:px-0; + & > * { + @apply snap-start; + } +} + +/* + * THE PAGE YOU ARE ON, in the nav. A rule under the word rather than a colour change: the nav is already + * sage on cream, and darkening it further reads as a hover, not as "you are here". + */ +@utility is-here { + @apply text-bakery-900; + text-decoration: underline; + text-decoration-color: var(--color-bakery-500); + text-decoration-thickness: 2px; + text-underline-offset: 8px; +} + +/* + * The keyboard's way past the nav. Off-screen until focused, then a normal-looking button in the corner — + * the pattern only works if it becomes visible, which is the half everyone forgets. + */ +@utility skip-link { + @apply sr-only; + &:focus { + @apply not-sr-only fixed left-4 top-4 z-50 pill-sage; + } +} + +/* + * An opening paragraph set like a printed one. Used once, on the story — a drop cap on every page would + * be a costume rather than a voice. + */ +@utility opening-para { + &::first-letter { + @apply font-adbhashitha text-6xl text-bakery-700 float-left mr-3 mt-1; + line-height: 0.75; + } +} diff --git a/src/main/styles/package-lock.json b/src/main/styles/package-lock.json new file mode 100644 index 0000000..6c59446 --- /dev/null +++ b/src/main/styles/package-lock.json @@ -0,0 +1,1202 @@ +{ + "name": "itsthevine-styles", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "itsthevine-styles", + "version": "0.1.0", + "devDependencies": { + "@tailwindcss/cli": "4.3.3", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "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/@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": { + "@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" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "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": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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", + "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/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" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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==", + "dev": true, + "license": "0BSD", + "optional": true + } + } +} diff --git a/src/main/styles/package.json b/src/main/styles/package.json new file mode 100644 index 0000000..2e5050e --- /dev/null +++ b/src/main/styles/package.json @@ -0,0 +1,15 @@ +{ + "name": "itsthevine-styles", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "tailwindcss -i site.css -o ../../../target/classes/static/css/site.css --minify", + "build:css": "tailwindcss -i site.css -o ../../../target/classes/static/css/site.css --minify", + "watch": "tailwindcss -i site.css -o ../../../target/classes/static/css/site.css --watch" + }, + "devDependencies": { + "@tailwindcss/cli": "4.3.3", + "tailwindcss": "4.3.3" + } +} diff --git a/src/main/styles/site.css b/src/main/styles/site.css new file mode 100644 index 0000000..d1a4d30 --- /dev/null +++ b/src/main/styles/site.css @@ -0,0 +1,26 @@ +/* + * The site's one stylesheet, compiled by the Tailwind CLI into target/classes/static/css. + * + * It lives under src/main with the rest of the app's source, and it has to stay in the same directory as + * package.json: Tailwind resolves `@import "tailwindcss"` by walking up from the CSS file looking for + * node_modules. There is no bundler and no framework here — the site and the admin are both + * server-rendered HTML, and this is the whole of the asset pipeline. + * + * Imported in cascade order: names, then bare elements, then classes. + */ +@import "tailwindcss"; + +@import "./theme.css"; +@import "./base.css"; +@import "./type.css"; +@import "./components.css"; +@import "./admin.css"; + +/* + * Where Tailwind looks for class names. Every template, and 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. + */ +@source "../resources/templates"; +@source "../resources/static/js"; diff --git a/src/main/styles/theme.css b/src/main/styles/theme.css new file mode 100644 index 0000000..0e4310f --- /dev/null +++ b/src/main/styles/theme.css @@ -0,0 +1,72 @@ +/* + * The brand: three typefaces and the sage-and-cream palette. Nothing here draws anything — these are the + * names the rest of the CSS and every template are written in. + * + * Fonts are served by Spring from /fonts rather than bundled, so the stylesheet can name a stable URL and + * the browser caches one copy across the whole site. + */ + +@font-face { + font-family: 'Raleway'; + src: url('/fonts/raleway-latin.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} +/* + * 306 KB became 8 KB. The file carried 1605 glyphs for 187 codepoints — alternates and scripts this site + * never sets — so it is subset to Latin and the punctuation it actually has, with every layout feature + * kept, and compressed as woff2. The headings are pixel-identical; that was checked, not assumed. + */ +@font-face { + font-family: 'AdBhashitha'; + src: url('/fonts/AdBhashitha.woff2') format('woff2'); + font-display: swap; +} +/* + * Recompressed, never subset: the wordmark's swashes come out of this font's alternates, and the logo is + * the one thing on the site that must not change shape. + */ +@font-face { + font-family: 'LeJour Script'; + src: url('/fonts/LeJour-Script.woff2') format('woff2'); + 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; + + /* + * CORNERS. Two decisions, named, so "how round is a card" is answered once rather than per template. + * + * A card is 12px. It was 24px (Tailwind's rounded-3xl), which is the roundest thing on the page and + * reads as a phone app; a bakery's printed card has a corner you can see. Fields are tighter again, and + * buttons stay fully round — the pill is the brand's button shape and always was. + */ + --radius-panel: 0.75rem; + --radius-field: 0.5rem; + + /* + * Shadows tinted with the darkest sage rather than black. A neutral grey shadow on a cream page reads + * as a UI panel floating over a screen; a warm one reads as card stock lying on a counter, which is + * what these are meant to be. + */ + --shadow-card: 0 1px 2px rgba(35, 43, 30, 0.04), 0 10px 30px -18px rgba(35, 43, 30, 0.28); + --shadow-card-lift: 0 2px 6px rgba(35, 43, 30, 0.06), 0 20px 44px -22px rgba(35, 43, 30, 0.4); + + --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; +} + diff --git a/src/main/styles/type.css b/src/main/styles/type.css new file mode 100644 index 0000000..9b00e96 --- /dev/null +++ b/src/main/styles/type.css @@ -0,0 +1,71 @@ +/* + * The typographic ladder, taken from the logo and walked down to something you can read a paragraph in. + * + * The lockup is the brand's whole voice: "The Vine" in LeJour Script over COFFEEHOUSE + BAKERY in + * AdBhashitha, letterspaced. Everything below is that idea, made progressively more readable: + * + * 1. .wordmark LeJour Script the name, and nothing else. A script face is unreadable at length, + * so it never leaves the lockup. + * 2. .h1 .h2 .h3 AdBhashitha the lockup's second font, carrying every heading. It keeps the + * wordmark's 0.01em letter-spacing so a heading sits on the same + * rhythm as the logo above it. + * 3. .eyebrow Raleway, caps the hinge: sans, but letterspaced like the lockup's tagline. Used + * .label above a heading and on small labels, it carries the brand into + * places a display face would be shouting. + * 4. .lede, body Raleway plain, and left alone. + * + * Sizes are declared here rather than in the templates so a heading is a decision made once. None of + * these set a colour: the same heading appears in sage on cream and in cream on sage, and a class that + * decided that would be fought with a utility every time. + */ + +@utility wordmark { + font-family: var(--font-lejour); + letter-spacing: 0.01em; +} + +@utility h1 { + @apply font-adbhashitha text-4xl md:text-5xl leading-tight text-balance; + letter-spacing: 0.01em; +} + +@utility h2 { + @apply font-adbhashitha text-3xl md:text-4xl leading-tight text-balance; + letter-spacing: 0.01em; +} + +@utility h3 { + @apply font-adbhashitha text-2xl md:text-3xl leading-snug; + letter-spacing: 0.01em; +} + +@utility h4 { + @apply font-adbhashitha text-xl leading-snug; + letter-spacing: 0.01em; +} + +/* Above a heading, or as a section's own small heading. */ +@utility eyebrow { + @apply text-xs sm:text-sm uppercase font-medium; + letter-spacing: 0.2em; +} + +/* Smaller, tighter: a column heading, a "good to know", a category chip. */ +@utility label { + @apply text-xs uppercase font-medium; + letter-spacing: 0.15em; +} + +/* The sentence under a page title. One size up from body, and balanced so it doesn't leave an orphan. */ +@utility lede { + @apply text-base sm:text-lg leading-relaxed text-balance; +} + +/* + * A price. The display face at a size nothing else uses, because on a catering card the number is the + * thing the eye should land on after the size. + */ +@utility price { + @apply font-adbhashitha text-4xl leading-none; + letter-spacing: 0.01em; +} diff --git a/src/test/java/com/itsthevine/web/AdminPagesTest.java b/src/test/java/com/itsthevine/web/AdminPagesTest.java new file mode 100644 index 0000000..066072c --- /dev/null +++ b/src/test/java/com/itsthevine/web/AdminPagesTest.java @@ -0,0 +1,222 @@ +package com.itsthevine.web; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +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.security.test.web.servlet.setup.SecurityMockMvcConfigurers; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +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 admin, signed in and driven the way a browser drives it: form posts and redirects. + * + *

    These replace the JSON admin's tests. The screen used to be React talking to {@code + * /api/admin/**}; it is now Thymeleaf forms, so what is worth asserting is that a form arrives bound + * correctly, that an edit lands, that a refusal comes back readable rather than as a stack trace, and + * that a structural button changes the draft without writing it. + * + *

    Runs with {@code SECURITY_MODE=OIDC}, because that is the only condition under which the admin + * exists at all. + */ +@SpringBootTest(properties = { + "SECURITY_MODE=OIDC", + // Endpoints stated outright rather than an issuer-uri, which would make Spring fetch the + // discovery document at startup — that needs the network and a real identity provider. + "spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize", + "spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token", + "spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks", + "spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo", + "spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username", + "spring.security.oauth2.client.registration.authentik.client-id=test", + "spring.security.oauth2.client.registration.authentik.client-secret=test", + "spring.security.oauth2.client.registration.authentik.scope=openid,profile,email", + "spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code", + "spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}", + "platform.contact.to=test@example.com", + "platform.contact.from=noreply@example.com", + "platform.storage.access-key=test", + "platform.storage.secret-key=test" +}) +@Testcontainers +// Rolled back per test, so each starts from the seeded catalogue. MockMvc runs the controller on this +// thread, which is what lets the test's transaction wrap the whole request. +@Transactional +class AdminPagesTest { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @Autowired + WebApplicationContext context; + + MockMvc mvc; + + @BeforeEach + void setUp() { + // .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain out. + mvc = MockMvcBuilders.webAppContextSetup(context) + .apply(SecurityMockMvcConfigurers.springSecurity()) + .build(); + } + + @Test + void theCatalogueScreenShowsWhatIsOnThePageWithItsPhotos() throws Exception { + mvc.perform(get("/admin").with(user("morissa"))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("76th Birthday Cake"))) + // The photo URL and the key beside it: the key is what the arrange buttons name it by. + .andExpect(content().string(containsString("products/76th_birthday_cake.webp"))) + .andExpect(content().string(containsString("On the page (40)"))) + // The filter list, with the count that decides whether Delete is offered. + .andExpect(content().string(containsString("Cookies"))); + } + + @Test + void renamingAnItemLandsAndSaysSo() throws Exception { + mvc.perform(post("/admin/items/1").with(user("morissa")).with(csrf()) + .param("do", "save") + .param("name", "76th Birthday Cake (chocolate)") + .param("category", "Cakes")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin")) + .andExpect(flash().attribute("done", containsString("Saved"))); + + mvc.perform(get("/admin").with(user("morissa"))) + .andExpect(content().string(containsString("76th Birthday Cake (chocolate)"))); + } + + @Test + void aRefusalComesBackAsASentenceTheEditorCanActOn() throws Exception { + // Cookies has items filed under it, and deleting the button shouldn't decide what happens to them. + mvc.perform(post("/admin/categories/1").with(user("morissa")).with(csrf()).param("do", "delete")) + .andExpect(redirectedUrl("/admin")) + .andExpect(flash().attribute("problem", containsString("still filed under Cookies"))); + } + + @Test + void movingAnItemUpFromTheTopIsNotAnError() throws Exception { + // The button is disabled in the page, but a stale page could still post this. + mvc.perform(post("/admin/items/1").with(user("morissa")).with(csrf()).param("do", "move:-1")) + .andExpect(redirectedUrl("/admin")) + .andExpect(flash().attributeCount(0)); + } + + @Test + void theTableEditorRendersTheStoredTableAsAForm() throws Exception { + mvc.perform(get("/admin/catering/tables/1").with(user("morissa"))) + .andExpect(status().isOk()) + // Indexed names are what let Spring bind the grid back into the right cells. + .andExpect(content().string(containsString("name=\"columns[0].label\""))) + .andExpect(content().string(containsString("name=\"lines[0].values[1]\""))) + .andExpect(content().string(containsString("value=\"18 items\""))) + // The price round-trips as text: it came out "$24" and goes back the same way. + .andExpect(content().string(containsString("value=\"$24\""))); + } + + @Test + void savingTheTableWritesEveryCell() throws Exception { + mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf()) + .param("do", "save") + .param("name", "Office boxes") + .param("blurb", "For meetings.") + .param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$26") + .param("lines[0].id", "1").param("lines[0].label", "Mini muffins") + .param("lines[0].values[0]", "14 items") + .param("notes[0]", "Two days' notice, please.")) + .andExpect(redirectedUrl("/admin/catering")) + .andExpect(flash().attribute("done", containsString("Saved the Office boxes table"))); + + mvc.perform(get("/api/catering")) + .andExpect(content().string(containsString("Office boxes"))) + .andExpect(content().string(containsString("$26"))) + .andExpect(content().string(containsString("14 items"))); + } + + @Test + void addingAColumnKeepsWhatWasTypedAndWritesNothing() throws Exception { + mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf()) + .param("do", "add-column") + .param("name", "Office") + .param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24") + .param("lines[0].id", "1").param("lines[0].label", "Mini muffins") + // A cell edited but not yet saved: it has to survive the round trip. + .param("lines[0].values[0]", "13 items")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("value=\"13 items\""))) + // The new column exists in the form, and every line grew an entry to match it. + .andExpect(content().string(containsString("name=\"columns[1].label\""))) + .andExpect(content().string(containsString("name=\"lines[0].values[1]\""))) + .andExpect(content().string(containsString("Not saved yet"))); + + // And nothing was written: the live page still says what it said. + mvc.perform(get("/api/catering")) + .andExpect(content().string(containsString("12 items"))); + } + + @Test + void removingAColumnTakesItsCellsOutOfEveryLine() throws Exception { + mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf()) + .param("do", "remove-column:0") + .param("name", "Office") + .param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24") + .param("columns[1].id", "2").param("columns[1].label", "Medium").param("columns[1].price", "$32") + .param("lines[0].id", "1").param("lines[0].label", "Mini muffins") + .param("lines[0].values[0]", "12 items") + .param("lines[0].values[1]", "18 items")) + .andExpect(status().isOk()) + // What is left is the Medium column and, under it, Medium's entry — not Small's. + .andExpect(content().string(containsString("value=\"Medium\""))) + .andExpect(content().string(containsString("value=\"18 items\""))) + .andExpect(content().string(org.hamcrest.Matchers.not(containsString("value=\"12 items\"")))) + .andExpect(content().string(org.hamcrest.Matchers.not(containsString("name=\"columns[1].label\"")))); + } + + @Test + void aPriceThatIsntAPriceComesBackWithTheWorkStillInTheForm() throws Exception { + mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf()) + .param("do", "save") + .param("name", "Office") + .param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "ask us") + .param("lines[0].id", "1").param("lines[0].label", "Mini muffins") + .param("lines[0].values[0]", "12 items")) + // Not a redirect: a redirect would throw the work away and leave them guessing. + .andExpect(status().isOk()) + .andExpect(content().string(containsString("isn't a price"))) + .andExpect(content().string(containsString("value=\"ask us\""))); + } + + @Test + void thePageNotesAreReplacedByWhatTheFormSubmits() throws Exception { + mvc.perform(post("/admin/catering/notes").with(user("morissa")).with(csrf()) + .param("notes", "Prices may change.") + .param("notes", " ") + .param("notes", "Two weeks' notice for a wedding.")) + .andExpect(redirectedUrl("/admin/catering")); + + mvc.perform(get("/api/catering")) + .andExpect(content().string(containsString("Two weeks' notice for a wedding."))) + // The blank box was a deletion, not a note: two notes came back, not three. + .andExpect(content().string(containsString("Prices may change."))) + .andExpect(content().string(org.hamcrest.Matchers.not(containsString("\" \"")))); + } +} diff --git a/src/test/java/com/itsthevine/web/AdminSecurityTest.java b/src/test/java/com/itsthevine/web/AdminSecurityTest.java index 35ba57f..c99fcba 100644 --- a/src/test/java/com/itsthevine/web/AdminSecurityTest.java +++ b/src/test/java/com/itsthevine/web/AdminSecurityTest.java @@ -69,13 +69,25 @@ class AdminSecurityTest { } @Test - void everyAdminApiIsClosedToAnonymousVisitors() throws Exception { - // csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token. - mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized()); - mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized()); - mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized()); - mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"x\"}")) + void everyAdminWriteIsClosedToAnonymousVisitors() throws Exception { + // csrf() on the writes, so these assert AUTHORIZATION, not a missing token. The admin is pages + // and form posts now, so this is the whole surface — there is no JSON admin left to guard. + mvc.perform(get("/admin")).andExpect(status().isUnauthorized()); + mvc.perform(get("/admin/catering")).andExpect(status().isUnauthorized()); + mvc.perform(get("/admin/catering/tables/1")).andExpect(status().isUnauthorized()); + mvc.perform(post("/admin/items").with(csrf()).param("name", "Free cake").param("category", "Cakes")) + .andExpect(status().isUnauthorized()); + mvc.perform(post("/admin/items/1").with(csrf()).param("do", "delete")) + .andExpect(status().isUnauthorized()); + mvc.perform(post("/admin/categories").with(csrf()).param("name", "x")) + .andExpect(status().isUnauthorized()); + // The prices are the one thing on this site a stranger would most enjoy editing. + mvc.perform(post("/admin/catering/tables/1").with(csrf()) + .param("do", "save").param("name", "Free") + .param("columns[0].label", "Any").param("columns[0].price", "0") + .param("lines[0].label", "Everything").param("lines[0].values[0]", "yes")) + .andExpect(status().isUnauthorized()); + mvc.perform(post("/admin/catering/notes").with(csrf()).param("notes", "anything")) .andExpect(status().isUnauthorized()); } @@ -83,10 +95,12 @@ class AdminSecurityTest { void theAdminPageRedirectsABrowserToLogin() throws Exception { // Protecting /admin server-side is what makes sign-in work: a browser opening it is bounced to // Authentik and comes back signed in. The redirect only fires for a request that prefers HTML — - // the platform answers */* (a fetch/XHR) with a bare 401 so the SPA can handle it — so this - // must send a browser's Accept header to see the 302. (Verified against a running container.) + // the platform answers */* with a bare 401, which is why the checks above see 401 and this one + // has to send a browser's Accept header to see the 302. (Verified against a running container.) mvc.perform(get("/admin").header("Accept", "text/html,application/xhtml+xml")) .andExpect(status().is3xxRedirection()); + mvc.perform(get("/admin/catering").header("Accept", "text/html,application/xhtml+xml")) + .andExpect(status().is3xxRedirection()); } @Test @@ -94,12 +108,14 @@ class AdminSecurityTest { // Locking the admin must not lock the menu. mvc.perform(get("/api/products")).andExpect(status().isOk()); mvc.perform(get("/api/categories")).andExpect(status().isOk()); + mvc.perform(get("/api/catering")).andExpect(status().isOk()); } @Test void theContactFormStillNeedsItsCsrfToken() throws Exception { - // Enabling the security starter enables CSRF for the PUBLIC contact form too. Without the token - // it 403s; the SPA reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN. + // Enabling the security starter enables CSRF for the PUBLIC contact form too. Without a token it + // 403s. The page's own form carries a hidden field (Spring Security fills it in for any th:action + // form); this JSON endpoint needs the X-XSRF-TOKEN header from the cookie. mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"Ada\",\"email\":\"ada@example.com\",\"message\":\"hi\"}")) .andExpect(status().isForbidden()); diff --git a/src/test/java/com/itsthevine/web/CateringMenuTest.java b/src/test/java/com/itsthevine/web/CateringMenuTest.java new file mode 100644 index 0000000..27f929b --- /dev/null +++ b/src/test/java/com/itsthevine/web/CateringMenuTest.java @@ -0,0 +1,360 @@ +package com.itsthevine.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import com.itsthevine.web.domain.Money; + +import jakarta.persistence.EntityManager; + +/** + * The catering tables, against the real seeded spreadsheet — so the migration is covered too. + * + *

    Every test that writes runs inside the test's own transaction and is rolled back, so the seeded + * page is the same for each one. + */ +@SpringBootTest +@Testcontainers +@Transactional +class CateringMenuTest { + + @Container + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + // The contact starter refuses to start on a blank recipient, and this app has a + // ContactController, so the context needs one even to test the catering page. + registry.add("platform.contact.to", () -> "test@example.com"); + registry.add("platform.contact.from", () -> "noreply@example.com"); + registry.add("platform.storage.access-key", () -> "test"); + registry.add("platform.storage.secret-key", () -> "test"); + } + + @Autowired + CateringMenu catering; + + @Autowired + EntityManager entityManager; + + @Test + void carriesTheBakerysSpreadsheetIntoTheDatabase() { + // The prices, the sizes and the shape are the spreadsheet's (V4). The wording is not: V6 rewrote + // the cells as sentences a customer can read, since "6+6" and "4 dz" were notes to the baker. + List tables = catering.menu().packages(); + + assertThat(tables).extracting(CateringMenu.PackageView::name) + .containsExactly("Office boxes", "Parties", "Weddings"); + + CateringMenu.PackageView office = tables.get(0); + assertThat(office.tiers()).extracting(CateringMenu.TierView::label) + .containsExactly("Small", "Medium", "Large"); + assertThat(office.tiers()).extracting(CateringMenu.TierView::price) + .containsExactly("$24", "$32", "$40"); + // One line, not three: a Small box is twelve items mixed in sixes, not twelve of each thing. The + // choice is written into the line's name, which is the same rule the other two tables follow. + assertThat(office.rows()).extracting(CateringMenu.RowView::label) + .containsExactly("Any mix of mini muffins, mini scones and mini cinnamon rolls"); + assertThat(office.rows().get(0).values()).containsExactly("12 items", "18 items", "24 items"); + // "Small" says nothing about how many it feeds; this does. + assertThat(office.tiers()).extracting(CateringMenu.TierView::serves) + .containsExactly("About 6–8 people", "About 10–12 people", "About 15–20 people"); + // The party and wedding columns are named by head count already, so they don't repeat it. + assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::serves).containsOnlyNulls(); + assertThat(office.blurb()).contains("morning meeting"); + assertThat(office.notes()).anySatisfy(note -> assertThat(note).contains("Baked in sixes")); + + assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::label) + .containsExactly("15–20 people", "20–30 people", "30–40 people"); + assertThat(tables.get(2).tiers()).extracting(CateringMenu.TierView::price) + .containsExactly("$236", "$310", "$386"); + // Wedding delivery terms belong to the wedding table, not to the page. + assertThat(tables.get(2).notes()).anySatisfy(note -> assertThat(note).contains("delivery fee")); + // No cell anywhere still speaks in shorthand. + assertThat(tables).allSatisfy(table -> assertThat(table.rows()).allSatisfy(row -> + assertThat(row.values()).noneMatch(value -> value.matches(".*\\b(dz|B&G|in cake)\\b.*")))); + } + + @Test + void theWeddingLinesEachSaySomething() { + // The source spreadsheet is offset — the prices sit on its "cupcakes" line — and V4 carried that + // over literally, which left two lines with no quantity in any column: a row of dashes on the + // page. V5 reads it as a baker would (a 12x17 pan is the sheet pan; "cc or sc" is what the dozens + // count) and the guards in it mean an edit made in the admin since would have survived instead. + CateringMenu.PackageView weddings = catering.menu().packages().get(2); + + assertThat(weddings.rows()).extracting(CateringMenu.RowView::label) + .containsExactly("Bride & groom cake", "Sheet cakes or 12×17 bars", + "Cupcakes or sugar cookies"); + assertThat(weddings.rows().get(1).values()).containsExactly("Two pans", "Three pans", "Four pans"); + assertThat(weddings.rows().get(2).values()).containsExactly("Four dozen", "Five dozen", "Six dozen"); + // Nothing left that is blank the whole way across. + assertThat(weddings.rows()) + .noneMatch(row -> row.values().stream().allMatch(String::isEmpty)); + } + + @Test + void thePartyLinesEachSaySomethingToo() { + // Same offset as the wedding table: the quantities on "Cupcakes" were always + // cupcakes-or-sugar-cookies, with an empty "Sugar cookies" line beneath. + CateringMenu.PackageView parties = catering.menu().packages().get(1); + assertThat(parties.rows()).extracting(CateringMenu.RowView::label) + .containsExactly("Cake", "Cupcakes or sugar cookies"); + assertThat(parties.rows().get(0).values()) + .containsExactly("A 6-inch cake", "An 8-inch cake", "A 10-inch cake"); + assertThat(parties.rows()) + .noneMatch(row -> row.values().stream().allMatch(String::isEmpty)); + } + + @Test + void everyLineCarriesOneEntryPerColumn() { + // The invariant the whole aggregate exists to hold: if these ever fall out of step, a box is + // advertised at another box's price. + assertThat(catering.everything().packages()).allSatisfy(table -> + assertThat(table.rows()).allSatisfy(row -> + assertThat(row.values()).hasSameSizeAs(table.tiers()))); + } + + @Test + void keepsBlankCellsRatherThanCollapsingThem() { + // No seeded line is blank across any more, so this uses a table of its own making: a blank cell + // still has to survive a save, because dropping one would shorten the line and shift the rest. + CateringMenu.PackageView fresh = catering.add("Trays"); + catering.save(fresh.id(), new CateringMenu.PackageEdit("Trays", null, + List.of(new CateringMenu.TierEdit(null, "Small", "10", null), + new CateringMenu.TierEdit(null, "Large", "20", null)), + List.of(new CateringMenu.RowEdit(null, "Seasonal extras", List.of("", "A dozen"))), + List.of())); + + CateringMenu.RowView row = catering.everything().packages().getLast().rows().getFirst(); + assertThat(row.values()).containsExactly("", "A dozen"); + } + + @Test + void statesThePageWideTermsSeparatelyFromAnyOneTable() { + assertThat(catering.menu().notes()).hasSize(2); + assertThat(catering.menu().notes().get(0)).contains("price may change"); + } + + @Test + void writesMoneyTheWayAPriceListDoes() { + assertThat(Money.format(2400)).isEqualTo("$24"); + assertThat(Money.format(23600)).isEqualTo("$236"); + assertThat(Money.format(2450)).isEqualTo("$24.50"); + assertThat(Money.format(2405)).isEqualTo("$24.05"); + assertThat(Money.format(150000)).isEqualTo("$1,500"); + // "Ask us" is a legitimate price. It must not render as "$0". + assertThat(Money.format(null)).isNull(); + } + + @Test + void takesAPriceHoweverTheBakeryTypesIt() { + assertThat(Money.cents("24")).isEqualTo(2400); + assertThat(Money.cents("$24")).isEqualTo(2400); + assertThat(Money.cents(" $1,250.00 ")).isEqualTo(125000); + // The float route gives 2410.0000000000005 for this one. + assertThat(Money.cents("24.10")).isEqualTo(2410); + assertThat(Money.cents("")).isNull(); + assertThat(Money.cents(null)).isNull(); + + assertThatThrownBy(() -> Money.cents("ask us")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("isn't a price"); + assertThatThrownBy(() -> Money.cents("24.005")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("go to the cent"); + } + + @Test + void droppingAColumnTakesItsValuesWithIt() { + CateringMenu.PackageView office = catering.everything().packages().get(0); + Long medium = office.tiers().get(1).id(); + + catering.save(office.id(), withoutColumn(office, 1)); + // Straight back to the database: the point of this test is the rows that were deleted, and a + // session cache would happily show the right answer without them having been. + entityManager.flush(); + entityManager.clear(); + + CateringMenu.PackageView saved = catering.everything().packages().get(0); + assertThat(saved.tiers()).extracting(CateringMenu.TierView::label).containsExactly("Small", "Large"); + assertThat(saved.tiers()).extracting(CateringMenu.TierView::id).doesNotContain(medium); + assertThat(saved.rows().get(0).values()).containsExactly("12 items", "24 items"); + assertThat(saved.rows()).allSatisfy(row -> assertThat(row.values()).hasSize(2)); + } + + @Test + void refusesATableWhoseLinesAndColumnsDisagree() { + CateringMenu.PackageView office = catering.everything().packages().get(0); + // A column removed but the values left alone — the mistake that would shift every price. + CateringMenu.PackageEdit half = new CateringMenu.PackageEdit( + office.name(), office.blurb(), + asEdits(office).subList(0, 2), + asLines(office), + office.notes()); + + assertThatThrownBy(() -> catering.save(office.id(), half)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Any mix of mini muffins") + .hasMessageContaining("3 entries but the table has 2 columns"); + } + + @Test + void editingATableKeepsTheLinesItAlreadyHad() { + CateringMenu.PackageView office = catering.everything().packages().get(0); + Long muffins = office.rows().get(0).id(); + + List rows = new ArrayList<>(asLines(office)); + rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("12 items", "20 items", "24 items"))); + catering.save(office.id(), new CateringMenu.PackageEdit( + "Office boxes", "For meetings and staff mornings.", asEdits(office), rows, office.notes())); + + CateringMenu.PackageView saved = catering.everything().packages().get(0); + assertThat(saved.name()).isEqualTo("Office boxes"); + assertThat(saved.blurb()).isEqualTo("For meetings and staff mornings."); + // Same line, edited — not a new line that happens to read the same. + assertThat(saved.rows().get(0).id()).isEqualTo(muffins); + assertThat(saved.rows().get(0).values()).containsExactly("12 items", "20 items", "24 items"); + } + + @Test + void aNewTableStaysOffThePublicPageUntilItSaysSomething() { + CateringMenu.PackageView fresh = catering.add("Holiday boxes"); + + assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name) + .containsExactly("Office boxes", "Parties", "Weddings", "Holiday boxes"); + assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name) + .doesNotContain("Holiday boxes"); + + // Once it has a column and a line, it's a price table and it belongs on the page. + catering.save(fresh.id(), new CateringMenu.PackageEdit("Holiday boxes", null, + List.of(new CateringMenu.TierEdit(null, "Dozen", "18", "About 6 people")), + List.of(new CateringMenu.RowEdit(null, "Frosted cut-outs", List.of("12 items"))), + List.of())); + assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name) + .contains("Holiday boxes"); + } + + @Test + void deletingATableTakesItsColumnsLinesAndCellsWithIt() { + CateringMenu.PackageView weddings = catering.everything().packages().get(2); + + catering.remove(weddings.id()); + // Flushed on purpose: a delete that leaves its children behind fails against the real foreign + // keys, not in memory, and this one is a button on the admin screen. + entityManager.flush(); + entityManager.clear(); + + assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name) + .containsExactly("Office boxes", "Parties"); + // The page's own terms outlive any one table. + assertThat(catering.menu().notes()).hasSize(2); + } + + @Test + void putsTheTablesWhereTheEditorLeftThem() { + List tables = catering.everything().packages(); + List weddingsFirst = List.of(tables.get(2).id(), tables.get(0).id(), tables.get(1).id()); + + assertThat(catering.reorder(weddingsFirst)).extracting(CateringMenu.PackageView::name) + .containsExactly("Weddings", "Office boxes", "Parties"); + assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name) + .containsExactly("Weddings", "Office boxes", "Parties"); + } + + @Test + void refusesPricesAndHeadingsThatCantBeRight() { + CateringMenu.PackageView office = catering.everything().packages().get(0); + + assertThatThrownBy(() -> catering.save(office.id(), withColumnPrice(office, "-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("less than nothing"); + assertThatThrownBy(() -> catering.save(office.id(), withColumnPrice(office, "$50,000"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("$10,000"); + assertThatThrownBy(() -> catering.save(office.id(), withColumnLabel(office, " "))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Every column needs a heading"); + assertThatThrownBy(() -> catering.save(office.id(), withCell(office, "x".repeat(301)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("300 characters"); + } + + @Test + void replacingThePageNotesIsTheWholeList() { + assertThat(catering.replaceNotes(List.of("One term.", " ", "Another term."))) + // A blank line in the editor is not a note. + .containsExactly("One term.", "Another term."); + assertThat(catering.menu().notes()).containsExactly("One term.", "Another term."); + + assertThat(catering.replaceNotes(List.of("Only this one now."))).hasSize(1); + entityManager.flush(); + entityManager.clear(); + assertThat(catering.menu().notes()).containsExactly("Only this one now."); + } + + // --- turning what was read back into what the editor would send --------------------------- + + /** Note the round trip: what came back as "$24" goes out again as "$24" and must still mean 2400. */ + private static List asEdits(CateringMenu.PackageView table) { + return table.tiers().stream() + .map(t -> new CateringMenu.TierEdit(t.id(), t.label(), t.price(), t.serves())) + .toList(); + } + + private static List asLines(CateringMenu.PackageView table) { + return table.rows().stream() + .map(r -> new CateringMenu.RowEdit(r.id(), r.label(), r.values())) + .toList(); + } + + /** The table with one column gone, and every line's values narrowed to match — as the screen sends it. */ + private static CateringMenu.PackageEdit withoutColumn(CateringMenu.PackageView table, int column) { + List tiers = new ArrayList<>(asEdits(table)); + tiers.remove(column); + List rows = table.rows().stream().map(row -> { + List values = new ArrayList<>(row.values()); + values.remove(column); + return new CateringMenu.RowEdit(row.id(), row.label(), values); + }).toList(); + return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, rows, table.notes()); + } + + private static CateringMenu.PackageEdit withColumnPrice(CateringMenu.PackageView table, String price) { + List tiers = new ArrayList<>(asEdits(table)); + tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), tiers.get(0).label(), price, null)); + return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes()); + } + + private static CateringMenu.PackageEdit withColumnLabel(CateringMenu.PackageView table, String label) { + List tiers = new ArrayList<>(asEdits(table)); + tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), label, tiers.get(0).price(), null)); + return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes()); + } + + private static CateringMenu.PackageEdit withCell(CateringMenu.PackageView table, String value) { + List rows = new ArrayList<>(asLines(table)); + List values = new ArrayList<>(rows.get(0).values()); + values.set(0, value); + rows.set(0, new CateringMenu.RowEdit(rows.get(0).id(), rows.get(0).label(), values)); + return new CateringMenu.PackageEdit(table.name(), table.blurb(), asEdits(table), rows, table.notes()); + } +} diff --git a/src/test/java/com/itsthevine/web/HoursTest.java b/src/test/java/com/itsthevine/web/HoursTest.java new file mode 100644 index 0000000..c985857 --- /dev/null +++ b/src/test/java/com/itsthevine/web/HoursTest.java @@ -0,0 +1,71 @@ +package com.itsthevine.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +import org.junit.jupiter.api.Test; + +/** + * The opening times, as a list and as an answer. + * + *

    No Spring here on purpose: this is a calculation over a constant, and it should be testable at the + * speed of a calculation. Every "now" is passed in, so the Sunday-afternoon case doesn't need a Sunday. + */ +class HoursTest { + + private final Hours hours = new Hours(); + + @Test + void collapsesTheWeekTheWayASignWouldWriteIt() { + assertThat(hours.week()).extracting(Hours.Span::days, Hours.Span::hours) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("Tuesday – Friday", "7:00am – 2:00pm"), + org.assertj.core.groups.Tuple.tuple("Saturday", "7:00am – 12:00pm"), + org.assertj.core.groups.Tuple.tuple("Sunday – Monday", "Closed")); + } + + @Test + void theWeekStartsOnTheDayTheShopsWeekStarts() { + // Not Monday: the shop is shut on Monday, and a list that opens with a closed day reads as an + // apology. This is the order the printed list has always used. + assertThat(hours.week().getFirst().days()).startsWith("Tuesday"); + assertThat(hours.week().getLast().closed()).isTrue(); + } + + @Test + void saysHowLongIsLeftWhenTheShopIsOpen() { + Hours.Status wednesdayMorning = hours.at(chicago("2026-07-29T09:15")); + assertThat(wednesdayMorning.open()).isTrue(); + assertThat(wednesdayMorning.summary()).isEqualTo("Open until 2:00pm"); + } + + @Test + void countsTheMinuteOfOpeningAsOpenAndTheMinuteOfClosingAsShut() { + // The two edges anyone would get wrong, and the ones a customer stands at the door for. + assertThat(hours.at(chicago("2026-07-29T07:00")).open()).isTrue(); + assertThat(hours.at(chicago("2026-07-29T13:59")).open()).isTrue(); + assertThat(hours.at(chicago("2026-07-29T14:00")).open()).isFalse(); + assertThat(hours.at(chicago("2026-07-29T06:59")).open()).isFalse(); + } + + @Test + void tellsYouWhenItOpensAgainRatherThanJustSayingClosed() { + // Saturday afternoon, shut since noon: the next open day is Tuesday, three days away. + Hours.Status saturdayAfternoon = hours.at(chicago("2026-08-01T15:00")); + assertThat(saturdayAfternoon.open()).isFalse(); + assertThat(saturdayAfternoon.summary()).isEqualTo("Closed · opens Tuesday at 7:00am"); + + // Monday: tomorrow reads better than naming the day. + assertThat(hours.at(chicago("2026-08-03T10:00")).summary()) + .isEqualTo("Closed · opens tomorrow at 7:00am"); + + // Before opening on a day it does open, the day itself is redundant. + assertThat(hours.at(chicago("2026-07-29T06:30")).summary()).isEqualTo("Opens at 7:00am"); + } + + private static ZonedDateTime chicago(String localDateTime) { + return LocalDateTime.parse(localDateTime).atZone(Shop.ZONE); + } +} 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..485c10b 100644 --- a/src/test/java/com/itsthevine/web/PlatformContractTest.java +++ b/src/test/java/com/itsthevine/web/PlatformContractTest.java @@ -1,15 +1,40 @@ package com.itsthevine.web; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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. There is no SPA here at all now — no shell to forward to — so + * that assertion describes an app this no longer is. The contract's own test methods are package-private, + * so it 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 +44,58 @@ 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("routing is server-side: an unknown path is a 404, and so is /admin with no identity provider") + void routingIsServerSide() throws Exception { + mvc.perform(get("/some/client/side/route")).andExpect(status().isNotFound()); + // No SECURITY_MODE here, so AdminController does not exist — "no Authentik configured" means "no + // admin", and it 404s like any other unknown path rather than exposing catalogue writes. + // AdminSecurityTest covers the other half: with OIDC on, /admin exists and needs a login. + mvc.perform(get("/admin")).andExpect(status().isNotFound()); + } } 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..e958d0e --- /dev/null +++ b/src/test/java/com/itsthevine/web/SiteControllerTest.java @@ -0,0 +1,185 @@ +package com.itsthevine.web; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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; + + @Autowired + com.itsthevine.web.domain.ContactEnquiryRepository enquiries; + + 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 boxes"))) + .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, in the wording a customer reads rather than the spreadsheet's. + // The office box is a total mixed in sixes, not three separate things. + .andExpect(content().string(containsString("Any mix of mini muffins"))) + .andExpect(content().string(containsString("18 items"))) + .andExpect(content().string(containsString("About 6–8 people"))) + .andExpect(content().string(containsString("Baked in sixes"))) + // One card per size, and one enquiry link per table — not one per card, which would have + // read "Ask about the 15–20 people". + // The size label is in the HTML as written; the small caps are CSS. + .andExpect(content().string(containsString("Large"))) + .andExpect(content().string(containsString("Ask about office boxes"))) + // The price grid is gone: it was the source spreadsheet, rendered. + .andExpect(content().string(not(containsString("alert(1)")) + .andExpect(status().isOk()) + .andExpect(content().string(not(containsString("")))) + .andExpect(content().string(not(containsString("like to ask about")))); + } + + @Test + void aFilledInTrapIsThankedAndThrownAway() throws Exception { + // The bot is told the same thing a person is told — anything else is a training signal — and + // nothing is recorded or sent. The enquiry table is what proves the second half. + mvc.perform(post("/contact") + .param("name", "Bot").param("email", "bot@example.com") + .param("message", "Cheap watches").param("website", "http://example.com")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Your message is on its way"))); + assertThat(enquiries.count()).isZero(); + } + + @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()); + } +}