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}

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