From d4d1af1e116e98263bab68e9eeb2b2cac5b980b3 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Tue, 18 Aug 2026 16:35:31 -0700 Subject: [PATCH 1/3] demo(recipe): add the vanilla recipe app on the Firebase JS SDK The plain-SDK half of the side-by-side comparison. A follow-up branch swaps in ReactFire, and the diff between the two is the deliverable. The homepage fetches the recipe list in a server component and hands it to a client component, which takes over with onSnapshot. Everything else is a client component. Search is absent by design, dropped from the requirements on 2026-08-18. Structured so the ReactFire swap touches five files and nothing else: firebase.ts, layout.tsx, session.ts, use-recipes.ts and a new providers file. No page and no client component imports firebase directly, which `grep -rl "firebase/" src/components` should keep returning empty. Verified against the repo's emulator suite on Node 26: - The recipe list is present in the server-rendered HTML from curl with no JS running, with a negative control on a title that does not exist. - Liking a recipe updates the count with no reload and no optimistic update, so the value came back through Firestore. - The cuisine filter returns only matching recipes. - Signing in redirects back through ?next=, and /create-recipe bounces to /signin when signed out without flashing the page. - tsc --noEmit and next build are clean. The typecheck was confirmed able to fail first, by injecting a type error. Not verified: /create-recipe calls Firebase AI Logic, which needs the product enabled on a real project. The call is written but has never run, and the button surfaces the raw error rather than catching it. AGENTS.md and CLAUDE.md are generated by `next dev` on every run and are committed so the tree stays clean. Their contents are Next's own instructions to coding agents, not ours. --- recipe-demo/.env.local.example | 9 + recipe-demo/.gitignore | 5 + recipe-demo/AGENTS.md | 9 + recipe-demo/CLAUDE.md | 1 + recipe-demo/firestore.rules | 24 + recipe-demo/next.config.ts | 5 + recipe-demo/package-lock.json | 2134 ++++++++++++++++++ recipe-demo/package.json | 26 + recipe-demo/scripts/seed.mjs | 71 + recipe-demo/src/app/create-recipe/page.tsx | 74 + recipe-demo/src/app/layout.tsx | 39 + recipe-demo/src/app/page.tsx | 21 + recipe-demo/src/app/signin/page.tsx | 71 + recipe-demo/src/components/RecipeBrowser.tsx | 33 + recipe-demo/src/components/RecipeCard.tsx | 58 + recipe-demo/src/components/RecipeList.tsx | 22 + recipe-demo/src/components/RequireAuth.tsx | 29 + recipe-demo/src/components/SessionNav.tsx | 38 + recipe-demo/src/lib/ai.ts | 32 + recipe-demo/src/lib/firebase.ts | 23 + recipe-demo/src/lib/recipes.ts | 50 + recipe-demo/src/lib/session-context.tsx | 18 + recipe-demo/src/lib/session.ts | 30 + recipe-demo/src/lib/types.ts | 15 + recipe-demo/src/lib/use-recipes.ts | 42 + recipe-demo/tsconfig.json | 41 + 26 files changed, 2920 insertions(+) create mode 100644 recipe-demo/.env.local.example create mode 100644 recipe-demo/.gitignore create mode 100644 recipe-demo/AGENTS.md create mode 100644 recipe-demo/CLAUDE.md create mode 100644 recipe-demo/firestore.rules create mode 100644 recipe-demo/next.config.ts create mode 100644 recipe-demo/package-lock.json create mode 100644 recipe-demo/package.json create mode 100644 recipe-demo/scripts/seed.mjs create mode 100644 recipe-demo/src/app/create-recipe/page.tsx create mode 100644 recipe-demo/src/app/layout.tsx create mode 100644 recipe-demo/src/app/page.tsx create mode 100644 recipe-demo/src/app/signin/page.tsx create mode 100644 recipe-demo/src/components/RecipeBrowser.tsx create mode 100644 recipe-demo/src/components/RecipeCard.tsx create mode 100644 recipe-demo/src/components/RecipeList.tsx create mode 100644 recipe-demo/src/components/RequireAuth.tsx create mode 100644 recipe-demo/src/components/SessionNav.tsx create mode 100644 recipe-demo/src/lib/ai.ts create mode 100644 recipe-demo/src/lib/firebase.ts create mode 100644 recipe-demo/src/lib/recipes.ts create mode 100644 recipe-demo/src/lib/session-context.tsx create mode 100644 recipe-demo/src/lib/session.ts create mode 100644 recipe-demo/src/lib/types.ts create mode 100644 recipe-demo/src/lib/use-recipes.ts create mode 100644 recipe-demo/tsconfig.json diff --git a/recipe-demo/.env.local.example b/recipe-demo/.env.local.example new file mode 100644 index 00000000..a7ce3909 --- /dev/null +++ b/recipe-demo/.env.local.example @@ -0,0 +1,9 @@ +# Emulators are the default. Set to false to run against a real project. +NEXT_PUBLIC_USE_EMULATORS=true + +# With emulators on, only the project id matters and it must match whatever the +# emulator suite was started with (npm scripts at the repo root use rxfire-525a3). +NEXT_PUBLIC_FIREBASE_PROJECT_ID=rxfire-525a3 +NEXT_PUBLIC_FIREBASE_API_KEY=fake-api-key +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_APP_ID= diff --git a/recipe-demo/.gitignore b/recipe-demo/.gitignore new file mode 100644 index 00000000..a45f9bbd --- /dev/null +++ b/recipe-demo/.gitignore @@ -0,0 +1,5 @@ +node_modules +.next +next-env.d.ts +*.tsbuildinfo +.env.local diff --git a/recipe-demo/AGENTS.md b/recipe-demo/AGENTS.md new file mode 100644 index 00000000..643577df --- /dev/null +++ b/recipe-demo/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/recipe-demo/CLAUDE.md b/recipe-demo/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/recipe-demo/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/recipe-demo/firestore.rules b/recipe-demo/firestore.rules new file mode 100644 index 00000000..470a3ddd --- /dev/null +++ b/recipe-demo/firestore.rules @@ -0,0 +1,24 @@ +// Rules for this demo's data model. NOT the rules the local emulator loads: +// the repo root's firebase.json points at the root firestore.rules, which is +// open by design because reactfire's own test suite depends on it. Deploy these +// to whichever project the demo runs against. +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + match /recipes/{recipeId} { + allow read: if true; + allow create: if request.auth != null; + + // Signed-in users may only ever add or remove their own uid from likedBy. + allow update: if request.auth != null + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likedBy']) + && request.resource.data.likedBy.toSet().difference(resource.data.likedBy.toSet()) + in [[].toSet(), [request.auth.uid].toSet()] + && resource.data.likedBy.toSet().difference(request.resource.data.likedBy.toSet()) + in [[].toSet(), [request.auth.uid].toSet()]; + + allow delete: if false; + } + } +} diff --git a/recipe-demo/next.config.ts b/recipe-demo/next.config.ts new file mode 100644 index 00000000..b22af960 --- /dev/null +++ b/recipe-demo/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/recipe-demo/package-lock.json b/recipe-demo/package-lock.json new file mode 100644 index 00000000..ddf07884 --- /dev/null +++ b/recipe-demo/package-lock.json @@ -0,0 +1,2134 @@ +{ + "name": "recipe-demo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recipe-demo", + "version": "0.0.0", + "dependencies": { + "@picocss/pico": "^2.1.1", + "firebase": "^12.17.1", + "next": "^16.3.1", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^5.9.2" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@firebase/ai": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.14.0.tgz", + "integrity": "sha512-TYEQqCQUTyVHuG/HVi9vau6F9kvEaS49o/hmdn/yUuN6ZXQkwIml2nNJTIBfjNl/r9LOxwUNILgcOY16nxObug==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.23", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.23.tgz", + "integrity": "sha512-34ALWXzWA6PTRUA5hipZmsm1RKzeecw5J1+qTCXsiMzwLqONC+GuTIQSdmm91MmTAEA+wG1Q5t0IFahcYQOqAA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/installations": "0.6.23", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.29", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.29.tgz", + "integrity": "sha512-allztvCvCUlItZzD97TiRAtGoFJzR1FQFmLxbaLc6PvgscqD9cl5NdKPTtka6keShVYXvCZJpzWcRoH4TME8rw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.23", + "@firebase/analytics-types": "0.8.4", + "@firebase/component": "0.7.4", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.4.tgz", + "integrity": "sha512-zQ+XTgkwH6CY/eUSHJRP7e4LxM30RCxlCmob5sy2axs25GE3Ny0XdgpDscMTHHQIGqWkxPXad4w2Mw9sCgT8zQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.16.0.tgz", + "integrity": "sha512-G+ZGEyVP8YTb3ay6A+XpcYgFH3sTESHcnHU/EyTktodqhz2BHkLq+QEP7IVwjiMX0cxYwpVKip0/wC0KZcn9vQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.13.0.tgz", + "integrity": "sha512-AbMttBKazQvGVXBZhQdVAdPzRhwHyJAY3Ghu5y2C7IZKIDIppzNYz0shTZ1mP4FBJa+28BuC4t+5h1Q6pT3Asg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.6.tgz", + "integrity": "sha512-2pzNEZEkX84jSqy6TH6FI1HSLA1lc7kakRUybBbKjg9YhIttPlW/XX3N9CDtChji2PTTPWVPZiWhB10exHfA+A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.13.0", + "@firebase/app-check-types": "0.5.4", + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.4.tgz", + "integrity": "sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.4.tgz", + "integrity": "sha512-xV7JsIyzVr15aA7f3Pi0rB9gdBuVubs89FGA8VkRYA4g0l78poADgdfrScgf7NndSg9mm7cR7PJyY0+t22KaGw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.16.tgz", + "integrity": "sha512-shQq37O8qELDzvsVwYPlDXwD1zlcrZ0m2bpBF5ov2HSbY8x+AHsnL5TtJ2e1JAfkQN05qHao1AfabS69PN6GiA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.16.0", + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.5.tgz", + "integrity": "sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.1" + } + }, + "node_modules/@firebase/auth": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.4.tgz", + "integrity": "sha512-s+NS1aV0DDyyfoIMeSz53HXnVTv7ufJjJfrP63XyaWHweJ5vOoxKWrTm5tO7S7PDqvyOa/Wi3oP0dgAo6JTMMA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.9.tgz", + "integrity": "sha512-/hHeTBmQ61+N5J1RECls+WfskZTY78JXr7aO5EMOfUpqJvDqvoS+568k0rp6Ss/4UWwBjadILs+H+SGy1zCS3A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.13.4", + "@firebase/auth-types": "0.13.1", + "@firebase/component": "0.7.4", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.5.tgz", + "integrity": "sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.1.tgz", + "integrity": "sha512-0c1Mnid0uMDfGJHeUS4zfvBa4/CedJXotGy/n/NZJnBjwiJawt0ZYU+wH2VAVLiRCEfG2ncCkAX3yd1/2nrB7g==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.4.tgz", + "integrity": "sha512-tLpOaaCol9ugUIYp2R3CbWPPA8Ajg/papX/XHEy8U52b/QXH3BbX8tTJX9aShDCjp+9sMAxMLD94i7lresdugQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.7.3.tgz", + "integrity": "sha512-nHBFk3Ntl+NZCRIUG2d5j7I69P0otjyQ/duhVKLbw4+5cNke/F6RK1pdE5Jnf831/QOTs2Bd00LlxlZ+jNsb9w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.4.tgz", + "integrity": "sha512-D+j4+8uhGtNd1tVD+X+c8JrC4ppStGJKyujSQt2NPwdN26QcCk0BeIxue+UqspHkHiFHyQOimwlzjLewGq6S+A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.6.tgz", + "integrity": "sha512-mu7S/75UIajB1A5M9Vfojk69LttW55uABp9nHEtWrV/mIaSEwvoaIe9GySsEzS2EKFK5/3f5okcAuUbihhYeJg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/database": "1.1.4", + "@firebase/database-types": "1.0.21", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + }, + "peerDependenciesMeta": { + "@firebase/app": { + "optional": true + }, + "@firebase/app-compat": { + "optional": true + } + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.21.tgz", + "integrity": "sha512-SX1jUqhttKgg/m9dYRTvqU9QvucBooziWfA986r4cpsbi4zlsvewe424j3Vpduwd6DG1MSAMfBVT2VqA61FnkA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.5", + "@firebase/util": "1.15.2" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.17.0.tgz", + "integrity": "sha512-P9tof6pyO1bnLlMWbux+5O7WFJqlb7OTPMKxxOiXKYiQl7mxykAvxr1BFCgWeEXUU7DZxQncyJ040B0IhFVZCg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "@firebase/webchannel-wrapper": "1.0.6", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "re2js": "^2.8.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.12.tgz", + "integrity": "sha512-k2uX81Ao/S0jnFcWGPOQpKK1cPlJHvD9WIqh/RE1XBDP2yg5zhE4rHhSg1rtB11k39q3nKon9XLNDDrPjGclag==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/firestore": "4.17.0", + "@firebase/firestore-types": "3.0.4", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.4.tgz", + "integrity": "sha512-jGn+JSS4X9zZsrfu7Yw66v5YRdOLD1oyQh4USR0xWl4CUqV/DA6bNIXRPpxH/cUl3iVTNiP6MN7g+EL42A4qfA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.6", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.6.tgz", + "integrity": "sha512-9obLnzeQUivK5lmtGFOU2ucQ38BjTp+jpPtbfFp/mDsdVCvEpRqdWNvMMQ6aQwR4vcVc/utsvngm5BRkXbc7ZA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.4", + "@firebase/messaging-interop-types": "0.2.5", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.6.tgz", + "integrity": "sha512-dj9sOet+FIU91jeU4A3vGJoXHty7NqkSfjRLCwLgJXPDk1m72KFuxD3nlFgw/yXx/Fr7UjqzbxZ0LrIOdpx7+w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/functions": "0.13.6", + "@firebase/functions-types": "0.6.4", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.4.tgz", + "integrity": "sha512-zV6kgqtduR4rUAdC/ilS7kmb93XD7bEZoJDlVBZqlOw2uGGGCNBQBuleww2rr0Ulr3L9o2TDjumEt68/l1f9DQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.23", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.23.tgz", + "integrity": "sha512-MBkbcQfd+3qHjW+slsH4s7jH5qTdGlYpwqmxEZ7QcIpgDxu1SKyU0f+mCZhCt1BCacLNiOWF5L0R06N0LtlfMg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/util": "1.15.2", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.23.tgz", + "integrity": "sha512-isaXmjb9roM83eVeXAe+ZRNKYNsSo2s0aNM+cy04AAGEyVL/d8Aa11GwEXovRFeYjl9+1yRAOxRDTOukZRwTxA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/installations": "0.6.23", + "@firebase/installations-types": "0.5.4", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.4.tgz", + "integrity": "sha512-U2eFapdHwjb43Vx9o+Pmj4dFfvcHEK1IirEFLqMtWrTHvmdrS3gBpBD1kmJk/9HjsOtoHZxJ2Paoe79e+L1ZPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.1.tgz", + "integrity": "sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.13.1.tgz", + "integrity": "sha512-kL8fdjbNBI7hprlXJrUjktDWosrpT4JtfwXtVVevImPF/rBRAsC+LS/jIs+kgQVuotnvMhaBCgAFipBoY9YU9g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/installations": "0.6.23", + "@firebase/messaging-interop-types": "0.2.5", + "@firebase/util": "1.15.2", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.28", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.28.tgz", + "integrity": "sha512-/AmMqHRnSQhPsdeED3ocs+s30/tpFvZDiiwIYY2uXFRvLujo1fnbPOeCFoe4Y+dRy1LCSjpvJf+dy5ZTsxi1yg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/messaging": "0.13.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.5.tgz", + "integrity": "sha512-tUEKnaAP2Y/MNIqgnriPpV6e5l13Vs/+p2yrd6NGlncPJT9O3a8muYZtdnWe+IJ4fgKLHJVC79n/asxk/N5Msw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.13.tgz", + "integrity": "sha512-1u6fuXP9cj0s+lkTFAspr/ttfPebPbEdpx+5Wdr4mPZbp8qH2KCMxOddEAR1ZMRa5GI0E7hDYSnolEmbqOFOAg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/installations": "0.6.23", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.26", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.26.tgz", + "integrity": "sha512-jgoocXLN6ao26xWQ8pzosmzQ33uLzGBJQPNK0NTbVy1XvIHr5pfgBf9hWLOxsWe+R7sJq5bjD+8ybXprmt61mA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/performance": "0.7.13", + "@firebase/performance-types": "0.2.4", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.4.tgz", + "integrity": "sha512-kJSEk7b0uhpcPRyL4SQ/GPujLqk52XNKcXlnsKDbWGAb9vugcLvOU3u6zfEdwd+d8hWJb5S5ZizV1JFFI0nkKg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.9.1.tgz", + "integrity": "sha512-nzQUSJnk1zAZEl2Q5O3I7Z61cYLK5JI4H6wyyOiHkVZ+bmgy1YXNNMptNbVjixMQ/eCzgA6nZRaC+1eBcJGUFA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/installations": "0.6.23", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.28", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.28.tgz", + "integrity": "sha512-kEO9Gn6fbmVj7eNUtZ6d59mLgUDUD0qo7aCicGOWNfuRWTaUv3CF9DMYychO61zaEQ3cfA+CEny4V1E8A1gRGA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/logger": "0.5.1", + "@firebase/remote-config": "0.9.1", + "@firebase/remote-config-types": "0.5.1", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.1.tgz", + "integrity": "sha512-cX/1LT6KQwkXzck2eSzeKnuvXZCyr8qaPpDcikoJs7jmI+oBOXixpDLeDtWj1U6GNMkIoXrEDNoyT2Ypcyp5/A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.4.tgz", + "integrity": "sha512-jfzEWZb3Fpsq3FwAB2ifoc8mcSh935qXdDou3TpyjDWa45hhNcZUv8/w28/10njByhfK7snbakKN30nwnzQ3/w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.4.tgz", + "integrity": "sha512-qSRgCB9f2R/nCp8t/8OC101cIFBFeUazlRInOMdzbnLzvrQBzEfx19SrR4pvdj/0+M+P/y8AK/a2s+3EB+B1Pw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.4", + "@firebase/storage": "0.14.4", + "@firebase/storage-types": "0.8.4", + "@firebase/util": "1.15.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.4.tgz", + "integrity": "sha512-BT7cwxJOx8SWwlQfrlC+bD/Sk3Cw+1odCi8UZNFNWTVZoPsBnA5W+mqtZzVnvsdJpXCFGSGQ7R7vOR6dtM/BRA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.2.tgz", + "integrity": "sha512-974pWIZVLDMc5GW5YAsj8y0XxULxIy/sPUy7tsxmWbF93KRIyh9xpuHlh0zDL+shUcf5nHDjFOg9YLiQ763eiA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.6.tgz", + "integrity": "sha512-Vr/Mqu79dMwGRAyGbJ4uN4+BtXB3/mRTdzetD1daWNeG8QaWuzhhbG77GltO5c0yYmYls8i250iX73624GJd7Q==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.1.tgz", + "integrity": "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.1.tgz", + "integrity": "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.1.tgz", + "integrity": "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.1.tgz", + "integrity": "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.1.tgz", + "integrity": "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.1.tgz", + "integrity": "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.1.tgz", + "integrity": "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.1.tgz", + "integrity": "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.1.tgz", + "integrity": "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@picocss/pico": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz", + "integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "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", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/firebase": { + "version": "12.17.1", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.17.1.tgz", + "integrity": "sha512-dhp41ye9jMQvhx5FwjMkf/hjDHJApl7gXmvzOZGvP0M7c/GZGUnQ4qvsvlOBkF0Pa7wAwHMdcpL0ON2pXCQ4Sw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.14.0", + "@firebase/analytics": "0.10.23", + "@firebase/analytics-compat": "0.2.29", + "@firebase/app": "0.16.0", + "@firebase/app-check": "0.13.0", + "@firebase/app-check-compat": "0.4.6", + "@firebase/app-compat": "0.5.16", + "@firebase/app-types": "0.9.5", + "@firebase/auth": "1.13.4", + "@firebase/auth-compat": "0.6.9", + "@firebase/data-connect": "0.7.3", + "@firebase/database": "1.1.4", + "@firebase/database-compat": "2.1.6", + "@firebase/firestore": "4.17.0", + "@firebase/firestore-compat": "0.4.12", + "@firebase/functions": "0.13.6", + "@firebase/functions-compat": "0.4.6", + "@firebase/installations": "0.6.23", + "@firebase/installations-compat": "0.2.23", + "@firebase/messaging": "0.13.1", + "@firebase/messaging-compat": "0.2.28", + "@firebase/performance": "0.7.13", + "@firebase/performance-compat": "0.2.26", + "@firebase/remote-config": "0.9.1", + "@firebase/remote-config-compat": "0.2.28", + "@firebase/storage": "0.14.4", + "@firebase/storage-compat": "0.4.4", + "@firebase/util": "1.15.2" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/next": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz", + "integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.1", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.1", + "@next/swc-darwin-x64": "16.3.1", + "@next/swc-linux-arm64-gnu": "16.3.1", + "@next/swc-linux-arm64-musl": "16.3.1", + "@next/swc-linux-x64-gnu": "16.3.1", + "@next/swc-linux-x64-musl": "16.3.1", + "@next/swc-win32-arm64-msvc": "16.3.1", + "@next/swc-win32-x64-msvc": "16.3.1", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "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/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/re2js": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", + "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "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/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/recipe-demo/package.json b/recipe-demo/package.json new file mode 100644 index 00000000..af26e735 --- /dev/null +++ b/recipe-demo/package.json @@ -0,0 +1,26 @@ +{ + "name": "recipe-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "seed": "node scripts/seed.mjs" + }, + "dependencies": { + "@picocss/pico": "^2.1.1", + "firebase": "^12.17.1", + "next": "^16.3.1", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^5.9.2" + } +} diff --git a/recipe-demo/scripts/seed.mjs b/recipe-demo/scripts/seed.mjs new file mode 100644 index 00000000..5d6800fb --- /dev/null +++ b/recipe-demo/scripts/seed.mjs @@ -0,0 +1,71 @@ +// Seeds the emulator (default) or a real project with a demo user and recipes. +// Idempotent: deterministic document ids plus setDoc, so re-running overwrites +// rather than duplicating. +import { initializeApp } from 'firebase/app'; +import { connectFirestoreEmulator, doc, getFirestore, setDoc } from 'firebase/firestore'; + +const useEmulators = process.env.NEXT_PUBLIC_USE_EMULATORS !== 'false'; +const projectId = process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ?? 'rxfire-525a3'; +const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? 'fake-api-key'; +const authHost = '127.0.0.1:9099'; + +const DEMO_EMAIL = 'demo@example.com'; +const DEMO_PASSWORD = 'password'; + +const RECIPES = [ + { id: 'cacio-e-pepe', title: 'Cacio e Pepe', cuisine: 'Italian', ingredients: ['320g tonnarelli', '150g pecorino romano', '2 tsp black peppercorns'], steps: ['Toast the peppercorns and crush them.', 'Cook the pasta until very al dente.', 'Emulsify pecorino with pasta water, then toss.'] }, + { id: 'ribollita', title: 'Ribollita', cuisine: 'Italian', ingredients: ['Stale sourdough', 'Cavolo nero', 'Cannellini beans', 'Soffritto'], steps: ['Build a soffritto.', 'Simmer with beans and greens.', 'Layer with bread and rest overnight.'] }, + { id: 'oyakodon', title: 'Oyakodon', cuisine: 'Japanese', ingredients: ['2 chicken thighs', '3 eggs', 'Dashi, soy, mirin', 'Short grain rice'], steps: ['Simmer chicken in seasoned dashi.', 'Pour beaten egg over in two stages.', 'Slide onto hot rice.'] }, + { id: 'agedashi-tofu', title: 'Agedashi Tofu', cuisine: 'Japanese', ingredients: ['Silken tofu', 'Potato starch', 'Tsuyu', 'Grated daikon'], steps: ['Drain the tofu well.', 'Dust and fry until crisp.', 'Serve in warm tsuyu.'] }, + { id: 'tacos-al-pastor', title: 'Tacos al Pastor', cuisine: 'Mexican', ingredients: ['Pork shoulder', 'Guajillo and achiote', 'Pineapple', 'Corn tortillas'], steps: ['Marinate the pork overnight.', 'Roast and char.', 'Chop and serve with pineapple.'] }, + { id: 'sopa-de-lima', title: 'Sopa de Lima', cuisine: 'Mexican', ingredients: ['Chicken broth', 'Limes', 'Tomato and habanero', 'Tortilla strips'], steps: ['Poach and shred the chicken.', 'Simmer the broth with lime.', 'Top with fried tortilla strips.'] }, + { id: 'dal-tadka', title: 'Dal Tadka', cuisine: 'Indian', ingredients: ['Toor dal', 'Ghee', 'Cumin, garlic, dried chilli', 'Tomato'], steps: ['Pressure cook the dal.', 'Bloom the tempering in ghee.', 'Pour over and stir through.'] }, + { id: 'tarte-tatin', title: 'Tarte Tatin', cuisine: 'French', ingredients: ['6 apples', '150g sugar', '80g butter', 'Puff pastry'], steps: ['Make a dry caramel.', 'Pack the apples in tightly.', 'Cover with pastry and bake, then invert.'] }, +]; + +async function seedUser() { + if (!useEmulators) { + console.log('Skipping user creation: only the Auth emulator accepts an unauthenticated signUp.'); + return; + } + + const response = await fetch( + `http://${authHost}/identitytoolkit.googleapis.com/v1/accounts:signUp?key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: DEMO_EMAIL, password: DEMO_PASSWORD, returnSecureToken: true }), + }, + ); + + if (response.ok) { + console.log(`Created ${DEMO_EMAIL} / ${DEMO_PASSWORD}`); + return; + } + + const body = await response.json(); + if (body?.error?.message === 'EMAIL_EXISTS') { + console.log(`${DEMO_EMAIL} already exists, leaving it alone.`); + return; + } + throw new Error(`Could not create the demo user: ${JSON.stringify(body)}`); +} + +async function seedRecipes() { + const app = initializeApp({ projectId, apiKey }); + const firestore = getFirestore(app); + if (useEmulators) { + connectFirestoreEmulator(firestore, '127.0.0.1', 8085); + } + + let createdAt = Date.now() - RECIPES.length * 60_000; + for (const { id, ...recipe } of RECIPES) { + createdAt += 60_000; + await setDoc(doc(firestore, 'recipes', id), { ...recipe, likedBy: [], createdAt: new Date(createdAt) }); + } + console.log(`Wrote ${RECIPES.length} recipes to ${projectId}.`); +} + +await seedUser(); +await seedRecipes(); +process.exit(0); diff --git a/recipe-demo/src/app/create-recipe/page.tsx b/recipe-demo/src/app/create-recipe/page.tsx new file mode 100644 index 00000000..da0d08ba --- /dev/null +++ b/recipe-demo/src/app/create-recipe/page.tsx @@ -0,0 +1,74 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { RequireAuth } from '@/components/RequireAuth'; +import { generateRecipe } from '@/lib/ai'; +import { createRecipe } from '@/lib/recipes'; +import { CUISINES, type Cuisine, type RecipeDraft } from '@/lib/types'; + +function CreateRecipe() { + const [cuisine, setCuisine] = useState(CUISINES[0]); + const [draft, setDraft] = useState(); + const [error, setError] = useState(); + const [pending, setPending] = useState(false); + + async function onGenerate() { + setPending(true); + setError(undefined); + setDraft(undefined); + try { + const generated = await generateRecipe(cuisine); + await createRecipe(generated); + setDraft(generated); + } catch (err) { + // Surfaced verbatim on purpose: when AI Logic is not enabled, or the + // billing account has lapsed, the raw message is the whole diagnosis. + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPending(false); + } + } + + return ( + <> +

Create a recipe

+ + + + + + {error && ( +
+ Generation failed +

{error}

+
+ )} + + {draft && ( +
+ {draft.title} was added. See it on the homepage. +
+ )} + + ); +} + +export default function CreateRecipePage() { + return ( + + + + ); +} diff --git a/recipe-demo/src/app/layout.tsx b/recipe-demo/src/app/layout.tsx new file mode 100644 index 00000000..de681f56 --- /dev/null +++ b/recipe-demo/src/app/layout.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import '@picocss/pico/css/pico.min.css'; +import { SessionProvider } from '@/lib/session-context'; +import { SessionNav } from '@/components/SessionNav'; + +export const metadata: Metadata = { + title: 'Recipe demo', + description: 'Firebase JS SDK recipe app', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+ +
+
{children}
+
+ + + ); +} diff --git a/recipe-demo/src/app/page.tsx b/recipe-demo/src/app/page.tsx new file mode 100644 index 00000000..67d01197 --- /dev/null +++ b/recipe-demo/src/app/page.tsx @@ -0,0 +1,21 @@ +import { RecipeBrowser } from '@/components/RecipeBrowser'; +import { fetchRecipes } from '@/lib/recipes'; + +// The recipe list is public, and rendering it on the server is the one place +// this app is not a client component. Rendered per request so a newly created +// recipe shows up on a refresh. +export const dynamic = 'force-dynamic'; + +export default async function HomePage() { + const recipes = await fetchRecipes('all'); + + return ( + <> +
+

Recipes

+

Everything below was rendered on the server, then kept live in the browser.

+
+ + + ); +} diff --git a/recipe-demo/src/app/signin/page.tsx b/recipe-demo/src/app/signin/page.tsx new file mode 100644 index 00000000..22a9b396 --- /dev/null +++ b/recipe-demo/src/app/signin/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, useState } from 'react'; +import { signIn } from '@/lib/session'; + +function SignInForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(); + const [pending, setPending] = useState(false); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + setPending(true); + setError(undefined); + try { + await signIn(email, password); + router.replace(searchParams.get('next') ?? '/'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPending(false); + } + } + + return ( +
+

Sign in

+ + + + + + {error && {error}} + + +
+ ); +} + +// Next 16 requires a Suspense boundary around useSearchParams. +export default function SignInPage() { + return ( + Loading}> + + + ); +} diff --git a/recipe-demo/src/components/RecipeBrowser.tsx b/recipe-demo/src/components/RecipeBrowser.tsx new file mode 100644 index 00000000..d35f2921 --- /dev/null +++ b/recipe-demo/src/components/RecipeBrowser.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useState } from 'react'; +import { RecipeList } from './RecipeList'; +import { useRecipes } from '@/lib/use-recipes'; +import { CUISINES, type Cuisine, type Recipe } from '@/lib/types'; + +export function RecipeBrowser({ initialRecipes }: { initialRecipes: Recipe[] }) { + const [cuisine, setCuisine] = useState('all'); + const { recipes, status, error } = useRecipes(cuisine, initialRecipes); + + return ( + <> + + + {error ? ( +
Could not load recipes: {error.message}
+ ) : ( + + )} + + ); +} diff --git a/recipe-demo/src/components/RecipeCard.tsx b/recipe-demo/src/components/RecipeCard.tsx new file mode 100644 index 00000000..0ca0931a --- /dev/null +++ b/recipe-demo/src/components/RecipeCard.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useState } from 'react'; +import { toggleLike } from '@/lib/recipes'; +import { useSession } from '@/lib/session-context'; +import type { Recipe } from '@/lib/types'; + +export function RecipeCard({ recipe }: { recipe: Recipe }) { + const { user } = useSession(); + const [pending, setPending] = useState(false); + const liked = user ? recipe.likedBy.includes(user.uid) : false; + + async function onToggleLike() { + if (!user) return; + setPending(true); + try { + await toggleLike(recipe.id, user.uid, liked); + } finally { + setPending(false); + } + } + + return ( +
+
+ {recipe.title} +
+ {recipe.cuisine} +
+ +
+ Ingredients and steps +
    + {recipe.ingredients.map((ingredient) => ( +
  • {ingredient}
  • + ))} +
+
    + {recipe.steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
+ +
+ + {!user && Sign in to like recipes.} +
+
+ ); +} diff --git a/recipe-demo/src/components/RecipeList.tsx b/recipe-demo/src/components/RecipeList.tsx new file mode 100644 index 00000000..b0d6a888 --- /dev/null +++ b/recipe-demo/src/components/RecipeList.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { RecipeCard } from './RecipeCard'; +import type { Recipe } from '@/lib/types'; + +export function RecipeList({ recipes, loading }: { recipes: Recipe[]; loading: boolean }) { + if (loading) { + return
Loading recipes
; + } + + if (recipes.length === 0) { + return
No recipes yet. Sign in and create one.
; + } + + return ( +
+ {recipes.map((recipe) => ( + + ))} +
+ ); +} diff --git a/recipe-demo/src/components/RequireAuth.tsx b/recipe-demo/src/components/RequireAuth.tsx new file mode 100644 index 00000000..3170e13d --- /dev/null +++ b/recipe-demo/src/components/RequireAuth.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { usePathname, useRouter } from 'next/navigation'; +import { useEffect, type ReactNode } from 'react'; +import { useSession } from '@/lib/session-context'; + +export function RequireAuth({ children }: { children: ReactNode }) { + const { user, status } = useSession(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + // Waiting for 'ready' is what stops a signed-in user being bounced to + // /signin on every hard reload, before onAuthStateChanged has fired. + if (status === 'ready' && !user) { + router.replace(`/signin?next=${encodeURIComponent(pathname)}`); + } + }, [status, user, router, pathname]); + + if (status === 'loading') { + return
Checking your session
; + } + + if (!user) { + return null; + } + + return <>{children}; +} diff --git a/recipe-demo/src/components/SessionNav.tsx b/recipe-demo/src/components/SessionNav.tsx new file mode 100644 index 00000000..0a3f31da --- /dev/null +++ b/recipe-demo/src/components/SessionNav.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Link from 'next/link'; +import { logOut } from '@/lib/session'; +import { useSession } from '@/lib/session-context'; + +export function SessionNav() { + const { user, status } = useSession(); + + if (status === 'loading') { + return ( +
  • + Loading +
  • + ); + } + + if (!user) { + return ( +
  • + + Sign in + +
  • + ); + } + + return ( + <> +
  • {user.email}
  • +
  • + +
  • + + ); +} diff --git a/recipe-demo/src/lib/ai.ts b/recipe-demo/src/lib/ai.ts new file mode 100644 index 00000000..8c0e22ca --- /dev/null +++ b/recipe-demo/src/lib/ai.ts @@ -0,0 +1,32 @@ +import { getAI, getGenerativeModel, Schema } from 'firebase/ai'; +import { app } from './firebase'; +import { CUISINES, type Cuisine, type RecipeDraft } from './types'; + +// Taken from the AI Studio export, which is the only place we have seen a model +// id confirmed against a working app. Swap here if AI Logic rejects it. +export const RECIPE_MODEL = 'gemini-3-flash-preview'; + +const recipeSchema = Schema.object({ + properties: { + title: Schema.string(), + cuisine: Schema.enumString({ enum: [...CUISINES] }), + ingredients: Schema.array({ items: Schema.string() }), + steps: Schema.array({ items: Schema.string() }), + }, +}); + +export async function generateRecipe(cuisine: Cuisine): Promise { + const model = getGenerativeModel(getAI(app), { + model: RECIPE_MODEL, + generationConfig: { + responseMimeType: 'application/json', + responseSchema: recipeSchema, + }, + }); + + const result = await model.generateContent( + `Invent one ${cuisine} recipe. Give it a title, a list of ingredients with quantities, and numbered steps.`, + ); + + return JSON.parse(result.response.text()) as RecipeDraft; +} diff --git a/recipe-demo/src/lib/firebase.ts b/recipe-demo/src/lib/firebase.ts new file mode 100644 index 00000000..a93081d3 --- /dev/null +++ b/recipe-demo/src/lib/firebase.ts @@ -0,0 +1,23 @@ +import { getApp, getApps, initializeApp, type FirebaseOptions } from 'firebase/app'; +import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore'; + +export const useEmulators = process.env.NEXT_PUBLIC_USE_EMULATORS !== 'false'; + +const config: FirebaseOptions = { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? 'fake-api-key', + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ?? 'rxfire-525a3', + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, +}; + +export const app = getApps().length ? getApp() : initializeApp(config); +export const firestore = getFirestore(app); + +// Next re-evaluates modules on fast refresh and renders this on both the server +// and the client, so connecting twice has to be impossible rather than unlikely. +const EMULATOR_SENTINEL = '__recipeDemoFirestoreEmulator'; + +if (useEmulators && !(EMULATOR_SENTINEL in globalThis)) { + Object.defineProperty(globalThis, EMULATOR_SENTINEL, { value: true }); + connectFirestoreEmulator(firestore, '127.0.0.1', 8085); +} diff --git a/recipe-demo/src/lib/recipes.ts b/recipe-demo/src/lib/recipes.ts new file mode 100644 index 00000000..95f39f97 --- /dev/null +++ b/recipe-demo/src/lib/recipes.ts @@ -0,0 +1,50 @@ +import { + addDoc, + arrayRemove, + arrayUnion, + collection, + doc, + getDocs, + orderBy, + query, + updateDoc, + where, + type QuerySnapshot, +} from 'firebase/firestore'; +import { firestore } from './firebase'; +import type { Cuisine, Recipe, RecipeDraft } from './types'; + +export const recipesCollection = collection(firestore, 'recipes'); + +export function recipeQuery(cuisine: Cuisine | 'all') { + return cuisine === 'all' + ? query(recipesCollection, orderBy('createdAt', 'desc')) + : query(recipesCollection, where('cuisine', '==', cuisine), orderBy('createdAt', 'desc')); +} + +// Server components may only hand plain JSON to client components, and a +// Firestore Timestamp is not that. Converting here keeps every caller honest. +export function toRecipes(snapshot: QuerySnapshot): Recipe[] { + return snapshot.docs.map((d) => { + const { createdAt, ...rest } = d.data(); + return { + id: d.id, + ...rest, + createdAt: createdAt?.toDate?.().toISOString() ?? new Date(0).toISOString(), + } as Recipe; + }); +} + +export async function fetchRecipes(cuisine: Cuisine | 'all' = 'all'): Promise { + return toRecipes(await getDocs(recipeQuery(cuisine))); +} + +export function createRecipe(draft: RecipeDraft) { + return addDoc(recipesCollection, { ...draft, likedBy: [], createdAt: new Date() }); +} + +export function toggleLike(recipeId: string, uid: string, liked: boolean) { + return updateDoc(doc(recipesCollection, recipeId), { + likedBy: liked ? arrayRemove(uid) : arrayUnion(uid), + }); +} diff --git a/recipe-demo/src/lib/session-context.tsx b/recipe-demo/src/lib/session-context.tsx new file mode 100644 index 00000000..408a48ce --- /dev/null +++ b/recipe-demo/src/lib/session-context.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { subscribeToSession, type Session } from './session'; + +const SessionContext = createContext({ user: null, status: 'loading' }); + +export function SessionProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState({ user: null, status: 'loading' }); + + useEffect(() => subscribeToSession(setSession), []); + + return {children}; +} + +export function useSession() { + return useContext(SessionContext); +} diff --git a/recipe-demo/src/lib/session.ts b/recipe-demo/src/lib/session.ts new file mode 100644 index 00000000..b98cbbb5 --- /dev/null +++ b/recipe-demo/src/lib/session.ts @@ -0,0 +1,30 @@ +'use client'; + +import { getAuth, connectAuthEmulator, onAuthStateChanged, signInWithEmailAndPassword, signOut, type User } from 'firebase/auth'; +import { app, useEmulators } from './firebase'; + +export const auth = getAuth(app); + +const EMULATOR_SENTINEL = '__recipeDemoAuthEmulator'; + +if (useEmulators && !(EMULATOR_SENTINEL in globalThis)) { + Object.defineProperty(globalThis, EMULATOR_SENTINEL, { value: true }); + connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true }); +} + +export interface Session { + user: User | null; + status: 'loading' | 'ready'; +} + +export function subscribeToSession(onChange: (session: Session) => void) { + return onAuthStateChanged(auth, (user) => onChange({ user, status: 'ready' })); +} + +export function signIn(email: string, password: string) { + return signInWithEmailAndPassword(auth, email, password); +} + +export function logOut() { + return signOut(auth); +} diff --git a/recipe-demo/src/lib/types.ts b/recipe-demo/src/lib/types.ts new file mode 100644 index 00000000..5ef7f260 --- /dev/null +++ b/recipe-demo/src/lib/types.ts @@ -0,0 +1,15 @@ +export const CUISINES = ['Italian', 'Japanese', 'Mexican', 'Indian', 'French'] as const; + +export type Cuisine = (typeof CUISINES)[number]; + +export interface Recipe { + id: string; + title: string; + cuisine: Cuisine; + ingredients: string[]; + steps: string[]; + likedBy: string[]; + createdAt: string; +} + +export type RecipeDraft = Omit; diff --git a/recipe-demo/src/lib/use-recipes.ts b/recipe-demo/src/lib/use-recipes.ts new file mode 100644 index 00000000..55e54738 --- /dev/null +++ b/recipe-demo/src/lib/use-recipes.ts @@ -0,0 +1,42 @@ +'use client'; + +import { onSnapshot } from 'firebase/firestore'; +import { useEffect, useRef, useState } from 'react'; +import { recipeQuery, toRecipes } from './recipes'; +import type { Cuisine, Recipe } from './types'; + +export type FeedStatus = 'hydrated' | 'live' | 'loading' | 'error'; + +/** + * Takes over from the server-rendered list: seeds with what the server already + * fetched, then switches to a live subscription without a loading flash. + */ +export function useRecipes(cuisine: Cuisine | 'all', initialRecipes: Recipe[]) { + const renderedCuisine = useRef(cuisine); + const [recipes, setRecipes] = useState(initialRecipes); + const [status, setStatus] = useState('hydrated'); + const [error, setError] = useState(); + + useEffect(() => { + // The server data only describes the cuisine the page was rendered for, so + // any other filter starts from nothing until the first snapshot lands. + if (cuisine !== renderedCuisine.current) { + setStatus('loading'); + } + + return onSnapshot( + recipeQuery(cuisine), + (snapshot) => { + setRecipes(toRecipes(snapshot)); + setStatus('live'); + setError(undefined); + }, + (err) => { + setError(err); + setStatus('error'); + }, + ); + }, [cuisine]); + + return { recipes, status, error }; +} diff --git a/recipe-demo/tsconfig.json b/recipe-demo/tsconfig.json new file mode 100644 index 00000000..a4763e45 --- /dev/null +++ b/recipe-demo/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "ES2022" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} From 1c4bf829db02484e501779547a0cc8731ab28a99 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Fri, 21 Aug 2026 10:02:39 -0700 Subject: [PATCH 2/3] demo(recipe): validate the signin redirect and constrain recipe writes From Armando's review of #797, items 1, 2 and 5. The `next` query parameter went straight to `router.replace`, which follows an absolute URL off-site. It is now resolved against the app's own origin with only the path kept, rejecting the absolute, protocol-relative, backslash and `javascript:` forms. The create rule constrained nothing but auth, so a recipe could be written without `likedBy`, `ingredients` or `steps`. The homepage is server rendered, so one such document failed the whole page rather than one card. The rule now pins the key set, the field types and the cuisine, and requires `likedBy` to start empty. The update rule compared `toSet()` results, and a set is blind to duplicates, so any signed-in user could write a uid many times to inflate another user's like count. A size check against the deduplicated list closes it. Verified against a Firestore emulator loading these rules: a 17-case matrix scores 9/17 on the rules as they stood, including all three duplicate-stuffing attacks, and 17/17 with the fixes, with liking, unliking and liking alongside another user still allowed. The redirect table covers 16 inputs and the pre-fix behaviour differs on 11 of them. --- recipe-demo/firestore.rules | 21 ++++++++++++++++++++- recipe-demo/src/app/signin/page.tsx | 3 ++- recipe-demo/src/lib/safe-next.ts | 13 +++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 recipe-demo/src/lib/safe-next.ts diff --git a/recipe-demo/firestore.rules b/recipe-demo/firestore.rules index 470a3ddd..4ef6d346 100644 --- a/recipe-demo/firestore.rules +++ b/recipe-demo/firestore.rules @@ -8,11 +8,30 @@ service cloud.firestore { match /databases/{database}/documents { match /recipes/{recipeId} { allow read: if true; - allow create: if request.auth != null; + + // The list is server-rendered, so one document missing an array field + // fails the whole homepage rather than one card. The shape is enforced + // here because `toRecipes` casts to Recipe without checking. + allow create: if request.auth != null + && request.resource.data.keys().hasOnly( + ['title', 'cuisine', 'ingredients', 'steps', 'likedBy', 'createdAt']) + && request.resource.data.keys().hasAll( + ['title', 'cuisine', 'ingredients', 'steps', 'likedBy', 'createdAt']) + && request.resource.data.title is string + && request.resource.data.cuisine in + ['Italian', 'Japanese', 'Mexican', 'Indian', 'French'] + && request.resource.data.ingredients is list + && request.resource.data.steps is list + && request.resource.data.createdAt is timestamp + && request.resource.data.likedBy == []; // Signed-in users may only ever add or remove their own uid from likedBy. + // The set difference is blind to duplicates, so the size check is what + // stops a uid being written many times to inflate the count. allow update: if request.auth != null && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likedBy']) + && request.resource.data.likedBy.size() == + request.resource.data.likedBy.toSet().size() && request.resource.data.likedBy.toSet().difference(resource.data.likedBy.toSet()) in [[].toSet(), [request.auth.uid].toSet()] && resource.data.likedBy.toSet().difference(request.resource.data.likedBy.toSet()) diff --git a/recipe-demo/src/app/signin/page.tsx b/recipe-demo/src/app/signin/page.tsx index 22a9b396..dfa4f2e0 100644 --- a/recipe-demo/src/app/signin/page.tsx +++ b/recipe-demo/src/app/signin/page.tsx @@ -2,6 +2,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useState } from 'react'; +import { safeNext } from '@/lib/safe-next'; import { signIn } from '@/lib/session'; function SignInForm() { @@ -18,7 +19,7 @@ function SignInForm() { setError(undefined); try { await signIn(email, password); - router.replace(searchParams.get('next') ?? '/'); + router.replace(safeNext(searchParams.get('next'), window.location.origin)); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { diff --git a/recipe-demo/src/lib/safe-next.ts b/recipe-demo/src/lib/safe-next.ts new file mode 100644 index 00000000..7aad85d0 --- /dev/null +++ b/recipe-demo/src/lib/safe-next.ts @@ -0,0 +1,13 @@ +// `next` comes from the query string, and the App Router will happily follow an +// absolute URL off-site, so the origin check has to live here. Resolving against +// our own origin and keeping only the path rejects absolute, protocol-relative, +// backslash and `javascript:` forms while leaving ordinary paths intact. +export function safeNext(next: string | null | undefined, origin: string): string { + let url: URL; + try { + url = new URL(next ?? '/', origin); + } catch { + return '/'; + } + return url.origin === origin ? `${url.pathname}${url.search}${url.hash}` : '/'; +} From 148cfdb5fe56deb2bf07e8225ed6d16166145de1 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Fri, 21 Aug 2026 10:24:16 -0700 Subject: [PATCH 3/3] demo(recipe): track which filter the recipe state describes From Armando's review of #797, the two optional items inside the hook. `renderedCuisine` was set at mount and never updated, so switching away from the rendered cuisine and back skipped `loading` and reported a ready feed while the state still held the previous filter's list. The ref now follows each snapshot, so it describes the data actually in hand. `FeedStatus` declared `hydrated` and `live` as separate states and no consumer told them apart. Measured against reactfire before dropping them: with `initialData` passed, `status` and `hasEmitted` are both saturated, and only `firstValuePromise` resolving distinguishes server data from the first live snapshot. Expressing the distinction on either side therefore means hand-rolled local state, so the type now says what the app distinguishes. Verified in the browser against the emulators, with the effect instrumented: all to Italian to all reports loading on both changes with the fix, and on only the first without it, which is the reported bug. --- recipe-demo/src/lib/use-recipes.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/recipe-demo/src/lib/use-recipes.ts b/recipe-demo/src/lib/use-recipes.ts index 55e54738..0bea99ec 100644 --- a/recipe-demo/src/lib/use-recipes.ts +++ b/recipe-demo/src/lib/use-recipes.ts @@ -5,22 +5,23 @@ import { useEffect, useRef, useState } from 'react'; import { recipeQuery, toRecipes } from './recipes'; import type { Cuisine, Recipe } from './types'; -export type FeedStatus = 'hydrated' | 'live' | 'loading' | 'error'; +export type FeedStatus = 'loading' | 'ready' | 'error'; /** * Takes over from the server-rendered list: seeds with what the server already * fetched, then switches to a live subscription without a loading flash. */ export function useRecipes(cuisine: Cuisine | 'all', initialRecipes: Recipe[]) { - const renderedCuisine = useRef(cuisine); + // Which filter the current `recipes` describe. It starts as the cuisine the + // server rendered and moves on with every snapshot, so switching away and + // back still shows loading rather than the previous filter's list. + const loadedCuisine = useRef(cuisine); const [recipes, setRecipes] = useState(initialRecipes); - const [status, setStatus] = useState('hydrated'); + const [status, setStatus] = useState('ready'); const [error, setError] = useState(); useEffect(() => { - // The server data only describes the cuisine the page was rendered for, so - // any other filter starts from nothing until the first snapshot lands. - if (cuisine !== renderedCuisine.current) { + if (cuisine !== loadedCuisine.current) { setStatus('loading'); } @@ -28,7 +29,8 @@ export function useRecipes(cuisine: Cuisine | 'all', initialRecipes: Recipe[]) { recipeQuery(cuisine), (snapshot) => { setRecipes(toRecipes(snapshot)); - setStatus('live'); + loadedCuisine.current = cuisine; + setStatus('ready'); setError(undefined); }, (err) => {