From 54710019d20b13b3450300292facf27d0024a67a Mon Sep 17 00:00:00 2001 From: austin Date: Sun, 26 Jul 2026 16:47:10 -0500 Subject: [PATCH] The admin is Thymeleaf too: no JavaScript framework left in the repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last React went with this. /admin and /admin/catering are pages of forms; every write is a POST and a redirect back, so the back button and reload do what they look like they do, a double-tap cannot repeat an upload, and there is no client-side state to lose — a reload is always the truth. The /api/admin/** endpoints went too: they existed for the React screen, and their logic now lives in Catalogue (extracted from the two deleted JSON controllers) and CateringMenu, which the pages call. THE TABLE EDITOR IS THE INTERESTING PART, because a catering table cannot be edited a field at a time — a column heading, its price and the entries beneath it only mean anything together. One form holds the whole table and every button submits it; `name="do"` says which was pressed and its value carries the position (`remove-column:2`). "Add a column" therefore 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 — so a half-built table with a blank heading never reaches the live page. A failed save comes back the same way, with the work still in the form and the reason above it; a redirect would throw the work away and leave them guessing which cell the message was about. Spring binds `lines[2].values[1]` into the right cell, which flat repeated parameters could not promise. Reordering moved to the server, where it always belonged: the browser used to compute the new order and send the whole list back, and now "move this up" arrives as an action. Same for arranging photos — one endpoint takes the key and -1/1/0 (earlier, later, remove), because those three buttons are the same edit. frontend/ became styles/: node, Tailwind and nothing else. It exists because Tailwind needs a compiler and the alternative is a hand-written stylesheet; there is no bundler and no framework. The admin's controls are @utility classes (v4 will only let you @apply a registered utility, and only a utility can take the `file:` variant the photo pickers use) — the same buttons the React screen had, from the same class strings it composed. Also fixed .gitignore, which still named frontend/: with styles/ unlisted, `git add -A` staged 1,626 files of node_modules. Verified against a running container, not only in tests: pressing "+ Column" returns the draft with an unsaved cell intact, a new column and a matching new entry on the line, "Not saved yet" — and the live page unchanged; Save then writes both columns with the price parsed from "48". Renaming and moving an item land on the products page. Deleting a category that is in use is refused with the sentence naming it. Removing the only photo of an item is refused, and that button is already disabled in the page. 51 tests (10 new): the form binding, the flash on success and on refusal, a structural button writing nothing, and the whole admin surface closed to anonymous visitors. PlatformContractTest's routing assertion now says what is true — an unknown path 404s, and so does /admin when no identity provider is configured, because AdminController only exists under OIDC. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 5 +- README.md | 68 +- frontend/index.html | 18 - frontend/package.json | 27 - frontend/site.css | 22 - frontend/src/App.tsx | 20 - frontend/src/components/admin/Catering.tsx | 579 ---------- frontend/src/components/admin/ui.tsx | 52 - frontend/src/index.css | 13 - frontend/src/lib/api.ts | 198 ---- frontend/src/main.tsx | 11 - frontend/src/pages/Admin.tsx | 690 ------------ frontend/tsconfig.json | 17 - frontend/vite.config.ts | 31 - pom.xml | 37 +- .../web/AdminCategoryController.java | 151 --- .../web/AdminCateringController.java | 334 +++++- .../com/itsthevine/web/AdminController.java | 161 +++ .../web/AdminProductController.java | 206 ---- .../java/com/itsthevine/web/Catalogue.java | 306 +++++ .../com/itsthevine/web/SiteController.java | 12 - .../resources/templates/admin/catalogue.html | 188 ++++ .../resources/templates/admin/catering.html | 85 ++ .../resources/templates/admin/layout.html | 58 + src/main/resources/templates/admin/table.html | 121 ++ .../itsthevine/web/AdminCateringApiTest.java | 150 --- .../com/itsthevine/web/AdminPagesTest.java | 221 ++++ .../com/itsthevine/web/AdminSecurityTest.java | 33 +- .../itsthevine/web/PlatformContractTest.java | 21 +- styles/admin.css | 50 + {frontend => styles}/package-lock.json | 1001 +---------------- styles/package.json | 15 + styles/site.css | 23 + {frontend/src => styles}/tokens.css | 0 34 files changed, 1625 insertions(+), 3299 deletions(-) delete mode 100644 frontend/index.html delete mode 100644 frontend/package.json delete mode 100644 frontend/site.css delete mode 100644 frontend/src/App.tsx delete mode 100644 frontend/src/components/admin/Catering.tsx delete mode 100644 frontend/src/components/admin/ui.tsx delete mode 100644 frontend/src/index.css delete mode 100644 frontend/src/lib/api.ts delete mode 100644 frontend/src/main.tsx delete mode 100644 frontend/src/pages/Admin.tsx delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/vite.config.ts delete mode 100644 src/main/java/com/itsthevine/web/AdminCategoryController.java create mode 100644 src/main/java/com/itsthevine/web/AdminController.java delete mode 100644 src/main/java/com/itsthevine/web/AdminProductController.java create mode 100644 src/main/java/com/itsthevine/web/Catalogue.java create mode 100644 src/main/resources/templates/admin/catalogue.html create mode 100644 src/main/resources/templates/admin/catering.html create mode 100644 src/main/resources/templates/admin/layout.html create mode 100644 src/main/resources/templates/admin/table.html delete mode 100644 src/test/java/com/itsthevine/web/AdminCateringApiTest.java create mode 100644 src/test/java/com/itsthevine/web/AdminPagesTest.java create mode 100644 styles/admin.css rename {frontend => styles}/package-lock.json (54%) create mode 100644 styles/package.json create mode 100644 styles/site.css rename {frontend/src => styles}/tokens.css (100%) diff --git a/.gitignore b/.gitignore index cf75f71..512f9ab 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. +styles/node_modules/ .idea/ *.iml .vscode/ diff --git a/README.md b/README.md index b5831dc..03db6ce 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ 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 a React SPA on Spring, now server-rendered. The look has -not changed through any of it. +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 @@ -12,8 +12,8 @@ not changed through any of it. |---|---| | Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` | | Pages | Thymeleaf, `src/main/resources/templates` — **no JavaScript** except one 100-line file for the product-card arrows | -| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css` | -| Admin | the one React screen that is left, served at `/admin` only | +| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css`. `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 | @@ -29,7 +29,8 @@ writes its own head, so that whole mechanism is deleted rather than ported. The someone, and the contact form is a form post. `platform.web.spa.enabled=false` follows from that: the platform's fallback forwards extension-less paths -to `/index.html`, which now holds nothing but the admin. `SiteController` maps `/admin` to it explicitly. +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 @@ -64,10 +65,10 @@ Everything. The pages arrive complete. ## /admin -**The last React in the repo.** The public pages are server-rendered; this screen is a Vite/React app -because it is not content — it is an editor, and the instant-feedback editing (reorder that applies -before the network answers, a whole price table arranged on screen and saved in one go) is the point of -it. Everything under `frontend/` builds only this, plus the site's stylesheet. +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 @@ -77,18 +78,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 catering tables are editable there too, but a table at a time rather than a field at a time. That -isn't a different 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. +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. -**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController`, -`AdminCategoryController` 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` 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. +**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. @@ -102,16 +116,18 @@ history. EXIF (including GPS from phone photos) is stripped by the re-encode. ## Local development ```bash -# the whole site (needs Postgres on :5432 with an itsthevine database) +# the whole site, admin included (needs Postgres on :5432 with an itsthevine database) mvn spring-boot:run # http://localhost:8080 -# just the stylesheet, while editing templates — watches and recompiles -cd frontend && npm install && npx tailwindcss -i site.css -o ../target/classes/static/css/site.css --watch - -# the admin screen, proxying /api to :8080 -cd frontend && npm run dev # http://localhost:2024/admin +# the stylesheet, while editing templates — watches and recompiles +cd styles && npm install && npm run watch ``` +`/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. diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index a494fc0..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - The Vine — admin - - -
- - - diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 29174e2..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "itsthevine-frontend", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc --noEmit && vite build", - "build:css": "tailwindcss -i site.css -o ../target/classes/static/css/site.css --minify", - "preview": "vite preview" - }, - "dependencies": { - "react": "^19.2.7", - "react-dom": "^19.2.7" - }, - "devDependencies": { - "@tailwindcss/cli": "4.3.3", - "@tailwindcss/vite": "4.3.3", - "@types/node": "^26.1.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "tailwindcss": "4.3.3", - "typescript": "^7.0.0", - "vite": "^8.1.3" - } -} diff --git a/frontend/site.css b/frontend/site.css deleted file mode 100644 index e44b5d6..0000000 --- a/frontend/site.css +++ /dev/null @@ -1,22 +0,0 @@ -/* - * The stylesheet for the server-rendered site. - * - * Compiled by the Tailwind CLI (`npm run build:css`) straight into target/classes/static/css: it is a - * source file, not a resource, and the generated stylesheet belongs in the build output rather than in - * src/main/resources next to it. - * - * It lives in this directory, beside the admin's stylesheet, because Tailwind resolves `@import - * "tailwindcss"` by walking up from the CSS file looking for node_modules — and node_modules is here. - * So this directory is the whole asset pipeline: one Tailwind, two stylesheets, one of them for pages - * that contain no JavaScript at all. - * - * @source points Tailwind at the templates, and at gallery.js — the product-card arrows are created in - * script, so their classes are only written down there. A utility exists in the output only if Tailwind - * saw it in one of these files, which is why a class name must never be assembled from pieces at - * runtime. - */ -@import "tailwindcss"; -@import "./src/tokens.css"; - -@source "../src/main/resources/templates"; -@source "../src/main/resources/static/js"; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index fd1caa7..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import AdminPage from '@/pages/Admin'; - -/** - * What's left of the React app: the admin screen, and nothing else. - * - * The shop front is server-rendered Thymeleaf now, so there are no client-side routes to route - * between — react-router went with the pages it used to switch. Spring serves this shell at /admin and - * only at /admin; every other URL is a page in src/main/resources/templates. - * - * The admin sits outside the public chrome deliberately: the nav would offer a signed-in editor links - * away from unsaved work, and the opening hours in the footer are noise on a screen whose whole job is - * the catalogue. - */ -const App = () => ( -
- -
-); - -export default App; diff --git a/frontend/src/components/admin/Catering.tsx b/frontend/src/components/admin/Catering.tsx deleted file mode 100644 index 653ff78..0000000 --- a/frontend/src/components/admin/Catering.tsx +++ /dev/null @@ -1,579 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { - addCateringTable, - adminCatering, - deleteCateringTable, - reorderCateringTables, - saveCateringNotes, - saveCateringTable, - type CateringTable, -} from '@/lib/api'; -import { - ARROW_DOWN, - ARROW_LEFT, - ARROW_RIGHT, - ARROW_UP, - CHECK, - Icon, - PLUS, - TRASH, - X, - danger, - field, - iconButton, - primary, - secondary, - shift, -} from '@/components/admin/ui'; - -/** - * The goodie box and catering price tables, editable by the person who quotes them. - * - * A table is edited as a table and saved in one go, unlike the catalogue next door where every change - * saves as you make it. That's not a different taste in interfaces: a column heading, its price and - * the entries beneath it only mean anything together, so they have to be moved, added and removed - * together. Adding a column here adds an empty entry to every line, and removing one takes its - * entries with it — the server refuses any table whose lines and columns disagree, because the - * alternative is the Large box quietly advertising the Medium box's contents at the Large price. - */ - -// --- what's on screen ------------------------------------------------------- - -type TierDraft = { id: number | null; label: string; price: string }; -type RowDraft = { id: number | null; label: string; values: string[] }; -type Draft = { name: string; blurb: string; tiers: TierDraft[]; rows: RowDraft[]; notes: string[] }; - -const draftOf = (table: CateringTable): Draft => ({ - name: table.name, - blurb: table.blurb ?? '', - // The price arrives written out ("$24"); it goes back as whatever the editor leaves in the box, and - // the server decides what that's worth. - tiers: table.tiers.map((tier) => ({ id: tier.id, label: tier.label, price: tier.price ?? '' })), - rows: table.rows.map((row) => ({ id: row.id, label: row.label, values: [...row.values] })), - notes: [...table.notes], -}); - -/** Notes are edited as a list; deleting one is an omission, exactly as the server expects. */ -const Notes = ({ - notes, - hint, - disabled, - onChange, -}: { - notes: string[]; - hint: string; - disabled?: boolean; - onChange: (notes: string[]) => void; -}) => ( -
-

{hint}

-
    - {notes.map((note, i) => ( -
  • - + + + + +

    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..ec7906f --- /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..9a450d2 --- /dev/null +++ b/src/main/resources/templates/admin/table.html @@ -0,0 +1,121 @@ + + + +
+ +
+ +
+
+

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/test/java/com/itsthevine/web/AdminCateringApiTest.java b/src/test/java/com/itsthevine/web/AdminCateringApiTest.java deleted file mode 100644 index ec78244..0000000 --- a/src/test/java/com/itsthevine/web/AdminCateringApiTest.java +++ /dev/null @@ -1,150 +0,0 @@ -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.put; -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.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.http.MediaType; -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 catering admin over HTTP, signed in: the paths, the JSON field names and the shape of a refusal. - * - *

{@code CateringMenuTest} covers what the tables mean; this covers the surface the admin screen - * actually calls. Both matter — a table can be modelled perfectly and still be unreachable because a - * URL has a typo in it, and the screen reads the sentence out of a ProblemDetail rather than showing - * a status code. - */ -@SpringBootTest(properties = { - "SECURITY_MODE=OIDC", - // Stated outright rather than via issuer-uri, which would fetch a discovery document at - // startup — that needs the network and a real identity provider. (As in AdminSecurityTest.) - "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 one starts from the seeded page. MockMvc runs the controller on this -// thread, which is what lets the test's transaction wrap the whole request. -@Transactional -class AdminCateringApiTest { - - @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 handsTheEditorEveryTableWithItsColumnsPricedAndItsLinesFilledIn() throws Exception { - mvc.perform(get("/api/admin/catering").with(user("morissa"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.packages.length()").value(3)) - .andExpect(jsonPath("$.packages[0].name").value("Office")) - // Written out, not a number the browser would have to format. - .andExpect(jsonPath("$.packages[0].tiers[0].price").value("$24")) - .andExpect(jsonPath("$.packages[0].rows[0].label").value("Mini muffins")) - .andExpect(jsonPath("$.packages[0].rows[0].values[1]").value("18 items")) - .andExpect(jsonPath("$.notes.length()").value(2)); - } - - @Test - void savesAWholeTableAtOnce() throws Exception { - String office = """ - {"name":"Office boxes","blurb":"For meetings.", - "tiers":[{"id":null,"label":"Dozen","price":"$18.50"}], - "rows":[{"id":null,"label":"Mini muffins","values":["12 items"]}], - "notes":["Two days' notice, please."]} - """; - - mvc.perform(put("/api/admin/catering/packages/1").with(user("morissa")).with(csrf()) - .contentType(MediaType.APPLICATION_JSON).content(office)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("Office boxes")) - // The column and line were new; they come back with ids so the next save edits them - // rather than adding more. - .andExpect(jsonPath("$.tiers[0].id").isNumber()) - .andExpect(jsonPath("$.tiers[0].price").value("$18.50")) - .andExpect(jsonPath("$.rows[0].id").isNumber()) - .andExpect(jsonPath("$.notes[0]").value("Two days' notice, please.")); - } - - @Test - void refusesAnArrangementThatWouldMisprintThePricesAndSaysWhy() throws Exception { - // Two columns, three entries on the line: exactly the mistake that shifts a box's contents. - String crooked = """ - {"name":"Parties", - "tiers":[{"id":null,"label":"Small","price":"54"},{"id":null,"label":"Large","price":"98"}], - "rows":[{"id":null,"label":"Cake","values":["6 in","8 in","10 in"]}], - "notes":[]} - """; - - mvc.perform(put("/api/admin/catering/packages/2").with(user("morissa")).with(csrf()) - .contentType(MediaType.APPLICATION_JSON).content(crooked)) - .andExpect(status().isBadRequest()) - // `detail` is the field the SPA shows the editor. - .andExpect(jsonPath("$.detail") - .value(containsString("3 entries but the table has 2 columns"))); - } - - @Test - void reordersTheTablesFromItsOwnPathRatherThanReadingOrderAsAnId() throws Exception { - // /packages/order and /packages/{id} are both PUT; Spring's literal-beats-template rule is - // what keeps "order" from arriving as a table id. - mvc.perform(put("/api/admin/catering/packages/order").with(user("morissa")).with(csrf()) - .contentType(MediaType.APPLICATION_JSON).content("{\"ids\":[3,1,2]}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].name").value("Weddings")) - .andExpect(jsonPath("$[2].name").value("Parties")); - } - - @Test - void replacesThePageNotesWithTheListItWasGiven() throws Exception { - mvc.perform(put("/api/admin/catering/notes").with(user("morissa")).with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("[\"Prices may change.\",\" \"]")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0]").value("Prices may change.")); - } -} 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..e29ea89 --- /dev/null +++ b/src/test/java/com/itsthevine/web/AdminPagesTest.java @@ -0,0 +1,221 @@ +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("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/delete").with(user("morissa")).with(csrf())) + .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/move").with(user("morissa")).with(csrf()).param("by", "-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 8bff624..843bb25 100644 --- a/src/test/java/com/itsthevine/web/AdminSecurityTest.java +++ b/src/test/java/com/itsthevine/web/AdminSecurityTest.java @@ -70,21 +70,24 @@ 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(get("/api/admin/catering")).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/delete").with(csrf())).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(put("/api/admin/catering/packages/1").with(csrf()).contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"Free\",\"tiers\":[],\"rows\":[],\"notes\":[]}")) + 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(put("/api/admin/catering/notes").with(csrf()).contentType(MediaType.APPLICATION_JSON) - .content("[\"anything\"]")) + mvc.perform(post("/admin/catering/notes").with(csrf()).param("notes", "anything")) .andExpect(status().isUnauthorized()); } @@ -92,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 diff --git a/src/test/java/com/itsthevine/web/PlatformContractTest.java b/src/test/java/com/itsthevine/web/PlatformContractTest.java index a167769..485c10b 100644 --- a/src/test/java/com/itsthevine/web/PlatformContractTest.java +++ b/src/test/java/com/itsthevine/web/PlatformContractTest.java @@ -1,7 +1,6 @@ package com.itsthevine.web; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -25,10 +24,9 @@ import org.testcontainers.utility.DockerImageName; *

It used to extend {@code PlatformWebContract} from platform-starter-test and inherit these five * assertions verbatim. It can't any more, and the reason is worth stating: the shared contract asserts * that an unknown extension-less path forwards to {@code /index.html}, because it was written when every - * app on the platform was a React SPA. This one is server-rendered now — {@code /index.html} holds - * nothing but the admin shell — so forwarding a mistyped URL there would answer with a blank page and a - * 200 instead of the site's own 404. The contract's own test methods are package-private, so the - * assertion cannot be overridden from here. + * 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). @@ -92,13 +90,12 @@ class PlatformContractTest { } @Test - @DisplayName("an unknown route is a 404, and only /admin serves the SPA shell") - void routingIsServerSideExceptForTheAdmin() throws Exception { + @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()); - // Assert the forward TARGET, not the body: MockMvc records a forward rather than executing it, - // so the body is empty here by design — which also means this passes before the admin is built. - mvc.perform(get("/admin")) - .andExpect(status().isOk()) - .andExpect(forwardedUrl("/index.html")); + // 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/styles/admin.css b/styles/admin.css new file mode 100644 index 0000000..8682d38 --- /dev/null +++ b/styles/admin.css @@ -0,0 +1,50 @@ +/* + * 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-md 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-md border border-bakery-300 + text-bakery-700 hover:bg-bakery-100 transition-colors + disabled:opacity-30 disabled:cursor-not-allowed; +} + +@utility field { + @apply 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; +} + +@utility card { + @apply rounded-lg border border-bakery-200 bg-white p-4 shadow-sm; +} + +@utility card-heading { + @apply font-adbhashitha text-xl text-bakery-800; +} diff --git a/frontend/package-lock.json b/styles/package-lock.json similarity index 54% rename from frontend/package-lock.json rename to styles/package-lock.json index bd90f7d..6c59446 100644 --- a/frontend/package-lock.json +++ b/styles/package-lock.json @@ -1,26 +1,15 @@ { - "name": "itsthevine-frontend", + "name": "itsthevine-styles", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "itsthevine-frontend", + "name": "itsthevine-styles", "version": "0.1.0", - "dependencies": { - "react": "^19.2.7", - "react-dom": "^19.2.7" - }, "devDependencies": { "@tailwindcss/cli": "4.3.3", - "@tailwindcss/vite": "4.3.3", - "@types/node": "^26.1.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "tailwindcss": "4.3.3", - "typescript": "^7.0.0", - "vite": "^8.1.3" + "tailwindcss": "4.3.3" } }, "node_modules/@emnapi/core": { @@ -126,16 +115,6 @@ "@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/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -458,270 +437,6 @@ "node": ">=0.10" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "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/@tailwindcss/cli": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", @@ -998,21 +713,6 @@ "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", @@ -1024,402 +724,6 @@ "tslib": "^2.4.0" } }, - "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/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1433,13 +737,6 @@ "node": ">=8" } }, - "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/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1464,24 +761,6 @@ "node": ">=10.13.0" } }, - "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/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1495,21 +774,6 @@ "node": ">=8" } }, - "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/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1868,25 +1132,6 @@ "node": ">=4" } }, - "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/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -1901,109 +1146,6 @@ "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/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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2035,23 +1177,6 @@ "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/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2072,126 +1197,6 @@ "dev": true, "license": "0BSD", "optional": true - }, - "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/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 - } - } } } } diff --git a/styles/package.json b/styles/package.json new file mode 100644 index 0000000..e63b9a2 --- /dev/null +++ b/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/styles/site.css b/styles/site.css new file mode 100644 index 0000000..a94c3d8 --- /dev/null +++ b/styles/site.css @@ -0,0 +1,23 @@ +/* + * The stylesheet for the server-rendered site. + * + * Compiled by the Tailwind CLI (`npm run build:css`) straight into target/classes/static/css: it is a + * source file, not a resource, and the generated stylesheet belongs in the build output rather than in + * src/main/resources next to it. + * + * This directory is the whole asset pipeline, and Tailwind is all that is left of it: the site and the + * admin are both server-rendered HTML, so there is no bundler, no framework and one stylesheet. It has + * to live here because Tailwind resolves `@import "tailwindcss"` by walking up from the CSS file looking + * for node_modules — and node_modules is here. + * + * @source points Tailwind at every template (public pages and admin alike) and at gallery.js — the + * product-card arrows are created in script, so their classes are only written down there. A utility + * exists in the output only if Tailwind saw it in one of these files, which is why a class name must + * never be assembled from pieces at runtime. + */ +@import "tailwindcss"; +@import "./tokens.css"; +@import "./admin.css"; + +@source "../src/main/resources/templates"; +@source "../src/main/resources/static/js"; diff --git a/frontend/src/tokens.css b/styles/tokens.css similarity index 100% rename from frontend/src/tokens.css rename to styles/tokens.css