commit 5d71d32e20e0059ef799a586ecff7b5fbae9d0bf Author: OpenCode Date: Sat Oct 24 15:40:44 2026 +0200 feat(core): add secure recipe APIs, precise scaling and versioned imports diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16f5616 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +.env +.env.*.local +coverage/ +playwright-report/ +test-results/ +uploads/ +*.tsbuildinfo +.DS_Store diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5e1beee --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +node_modules +.next +package-lock.json +public/examples +test-results +playwright-report +docs/screenshots +uploads diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..4926aa8 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{ "singleQuote": true, "trailingComma": "all", "printWidth": 100 } diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql new file mode 100644 index 0000000..4db0228 --- /dev/null +++ b/migrations/0001_initial.sql @@ -0,0 +1,14 @@ +CREATE TABLE recipes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), title text NOT NULL, + title_key text NOT NULL UNIQUE, published boolean NOT NULL DEFAULT false, + data jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX recipes_published_created ON recipes (published, created_at DESC); +CREATE INDEX recipes_data ON recipes USING gin (data); +CREATE TABLE admins (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), username text NOT NULL UNIQUE, password text NOT NULL); +CREATE TABLE sessions (token text PRIMARY KEY, admin_id uuid NOT NULL REFERENCES admins(id) ON DELETE CASCADE, expires timestamptz NOT NULL); +CREATE INDEX sessions_expires ON sessions (expires); +CREATE TABLE login_limits (key text PRIMARY KEY, count integer NOT NULL, reset timestamptz NOT NULL); +CREATE TABLE settings (key text PRIMARY KEY, value jsonb NOT NULL); +INSERT INTO settings VALUES ('categories', '["Alltagsküche", "Vegetarisch", "Für Gäste", "Süßes"]'), ('tags', '["Schnell", "Familienliebling", "Meal Prep"]'), ('household', '"Unsere Küche"'); +CREATE TABLE media (name text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now()); diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..e0499f1 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,26 @@ +/** @type {import('next').NextConfig} */ +const config = { + poweredByHeader: false, + serverExternalPackages: ['@node-rs/argon2', 'postgres', 'sharp'], + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'same-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { + key: 'Content-Security-Policy', + value: + "default-src 'self'; script-src 'self' 'unsafe-inline'" + + (process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : '') + + "; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; worker-src 'self'", + }, + ], + }, + ]; + }, +}; +export default config; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5002a1a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3697 @@ +{ + "name": "hamham", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hamham", + "version": "1.0.0", + "dependencies": { + "@fontsource/dm-sans": "^5.3.0", + "@fontsource/fraunces": "^5.3.0", + "@node-rs/argon2": "^2.2.2", + "decimal.js": "^10.6.0", + "dotenv": "^18.0.9", + "drizzle-orm": "^0.45.4", + "fast-xml-parser": "^5.11.2", + "lucide-react": "^1.61.0", + "motion": "^14.6.0", + "next": "^16.4.3", + "postgres": "^3.4.9", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "sharp": "^0.35.5", + "tsx": "^4.23.15", + "zod": "^4.6.7" + }, + "devDependencies": { + "@playwright/test": "^1.64.1", + "@types/node": "^26.6.5", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "prettier": "^3.9.10", + "typescript": "^7.0.2", + "vitest": "^5.0.5" + } + }, + "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/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/dm-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.3.0.tgz", + "integrity": "sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/fraunces": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/fraunces/-/fraunces-5.3.0.tgz", + "integrity": "sha512-LEHTOBiZufQzAvnAe2rZ8ZknbDS28Q9kzwb0L+gZJEmjLGr8hhZ/5NuHXwDuHBWhVXYoiGGXmrb0H6Ko/RrHTg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "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", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "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.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "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.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "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.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "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.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "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.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "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.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "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.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "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.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "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.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "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.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "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.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "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.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "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.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "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/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.3.tgz", + "integrity": "sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.3.tgz", + "integrity": "sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.3.tgz", + "integrity": "sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.3.tgz", + "integrity": "sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.3.tgz", + "integrity": "sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.3.tgz", + "integrity": "sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.3.tgz", + "integrity": "sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.3.tgz", + "integrity": "sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.3.tgz", + "integrity": "sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@node-rs/argon2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz", + "integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "2.2.2", + "@node-rs/argon2-android-arm64": "2.2.2", + "@node-rs/argon2-darwin-arm64": "2.2.2", + "@node-rs/argon2-darwin-x64": "2.2.2", + "@node-rs/argon2-freebsd-x64": "2.2.2", + "@node-rs/argon2-linux-arm-gnueabihf": "2.2.2", + "@node-rs/argon2-linux-arm64-gnu": "2.2.2", + "@node-rs/argon2-linux-arm64-musl": "2.2.2", + "@node-rs/argon2-linux-x64-gnu": "2.2.2", + "@node-rs/argon2-linux-x64-musl": "2.2.2", + "@node-rs/argon2-win32-arm64-msvc": "2.2.2", + "@node-rs/argon2-win32-ia32-msvc": "2.2.2", + "@node-rs/argon2-win32-x64-msvc": "2.2.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz", + "integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz", + "integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz", + "integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz", + "integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz", + "integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz", + "integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz", + "integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz", + "integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz", + "integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz", + "integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz", + "integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz", + "integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.32.tgz", + "integrity": "sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "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/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "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", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz", + "integrity": "sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==", + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.4", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.4.tgz", + "integrity": "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@netlify/db": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.1", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/framer-motion": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.6.0.tgz", + "integrity": "sha512-MzBgZxGgoHPfSLEXmo4o5XlZvb+nVRFdHtGjVhm6LMIEJKTt/8hNO023euL5mYyzc8llV/hQTfjnN68tNRHalw==", + "license": "MIT", + "dependencies": { + "motion-dom": "14.6.0", + "motion-utils": "14.6.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz", + "integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/motion": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-14.6.0.tgz", + "integrity": "sha512-qqaZbyubgk7x3zxjPgP1l3Br/KQXol/IIV1uqb358SoN4ApcqNuV7+wyDPg/XLErmuyQL5lL8JVI/fOmDsmBKQ==", + "license": "MIT", + "dependencies": { + "framer-motion": "14.6.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.6.0.tgz", + "integrity": "sha512-lIojTl9aYSPIkeOOSzBliy1nNvpdO3JtVzW2WgTXQlOP4ZMuJhtWO3fP7OD4TVFwlRucdvxlDSMkL+fEQnQ52A==", + "license": "MIT", + "dependencies": { + "motion-utils": "14.6.0" + } + }, + "node_modules/motion-utils": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.6.0.tgz", + "integrity": "sha512-bZJo2AmQ2aOFkaato6VXrTbkPG0XhMzivWUWSaI59pxyllj8QMQUFZ84MYRQnPkK+7dr4KldyJwF2+RM0sdq/w==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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.4.3", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.3.tgz", + "integrity": "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==", + "license": "MIT", + "dependencies": { + "@next/env": "16.4.3", + "@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.4.3", + "@next/swc-darwin-x64": "16.4.3", + "@next/swc-linux-arm64-gnu": "16.4.3", + "@next/swc-linux-arm64-musl": "16.4.3", + "@next/swc-linux-x64-gnu": "16.4.3", + "@next/swc-linux-x64-musl": "16.4.3", + "@next/swc-win32-arm64-msvc": "16.4.3", + "@next/swc-win32-x64-msvc": "16.4.3", + "sharp": "^0.35.5" + }, + "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/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "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/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "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", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "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.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz", + "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "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/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3ed7cd8 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "hamham", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "format": "prettier --write .", + "format:check": "prettier --check .", + "dev": "next dev --hostname 0.0.0.0", + "build": "next build", + "start": "next start --hostname 0.0.0.0", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:e2e": "playwright test", + "test:docker": "tsx scripts/verify-docker.ts", + "db:migrate": "tsx scripts/migrate.ts", + "admin:create": "tsx scripts/admin.ts", + "schema:generate": "tsx scripts/schema.ts" + }, + "dependencies": { + "@fontsource/dm-sans": "^5.3.0", + "@fontsource/fraunces": "^5.3.0", + "@node-rs/argon2": "^2.2.2", + "decimal.js": "^10.6.0", + "dotenv": "^18.0.9", + "drizzle-orm": "^0.45.4", + "fast-xml-parser": "^5.11.2", + "lucide-react": "^1.61.0", + "motion": "^14.6.0", + "next": "^16.4.3", + "postgres": "^3.4.9", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "sharp": "^0.35.5", + "tsx": "^4.23.15", + "zod": "^4.6.7" + }, + "devDependencies": { + "@playwright/test": "^1.64.1", + "@types/node": "^26.6.5", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "prettier": "^3.9.10", + "typescript": "^7.0.2", + "vitest": "^5.0.5" + } +} diff --git a/public/examples/hamham.schema.json b/public/examples/hamham.schema.json new file mode 100644 index 0000000..b75c612 --- /dev/null +++ b/public/examples/hamham.schema.json @@ -0,0 +1,349 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 1 + }, + "recipes": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "title": { + "type": "string", + "minLength": 2, + "maxLength": 180 + }, + "subtitle": { + "default": "", + "type": "string", + "maxLength": 200 + }, + "description": { + "default": "", + "type": "string", + "maxLength": 4000 + }, + "image": { + "default": "", + "anyOf": [ + { + "type": "string", + "pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$" + }, + { + "type": "string", + "const": "" + } + ] + }, + "images": { + "default": [], + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$" + } + }, + "categories": { + "default": [], + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 60 + } + }, + "tags": { + "default": [], + "maxItems": 30, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 60 + } + }, + "cuisine": { + "default": "", + "type": "string", + "maxLength": 80 + }, + "difficulty": { + "default": "Einfach", + "type": "string", + "enum": [ + "Einfach", + "Mittel", + "Anspruchsvoll" + ] + }, + "prepMinutes": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 10080 + }, + "cookMinutes": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 10080 + }, + "restMinutes": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 10080 + }, + "servings": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 10000 + }, + "ingredients": { + "minItems": 1, + "maxItems": 200, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,80}$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "amount": { + "default": null, + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1000000 + }, + { + "type": "null" + } + ] + }, + "unit": { + "default": "", + "type": "string", + "enum": [ + "g", + "kg", + "ml", + "l", + "Stück", + "TL", + "EL", + "Prise", + "Bund", + "" + ] + }, + "section": { + "default": "", + "type": "string", + "maxLength": 80 + }, + "note": { + "default": "", + "type": "string", + "maxLength": 200 + }, + "optional": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "amount", + "unit", + "section", + "note", + "optional" + ], + "additionalProperties": false + } + }, + "steps": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,80}$" + }, + "title": { + "default": "", + "type": "string", + "maxLength": 160 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 6000 + }, + "ingredients": { + "default": [], + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,80}$" + } + }, + "timers": { + "default": [], + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,80}$" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "seconds": { + "type": "integer", + "minimum": 1, + "maximum": 172800 + } + }, + "required": [ + "id", + "label", + "seconds" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "title", + "text", + "ingredients", + "timers" + ], + "additionalProperties": false + } + }, + "tips": { + "default": "", + "type": "string", + "maxLength": 6000 + }, + "storage": { + "default": "", + "type": "string", + "maxLength": 3000 + }, + "nutrition": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "kcal": { + "type": "number", + "minimum": 0 + }, + "protein": { + "type": "number", + "minimum": 0 + }, + "carbs": { + "type": "number", + "minimum": 0 + }, + "fat": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "kcal", + "protein", + "carbs", + "fat" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "published": { + "default": false, + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + } + }, + "required": [ + "title", + "subtitle", + "description", + "image", + "images", + "categories", + "tags", + "cuisine", + "difficulty", + "prepMinutes", + "cookMinutes", + "restMinutes", + "servings", + "ingredients", + "steps", + "tips", + "storage", + "nutrition", + "published" + ], + "additionalProperties": false + } + } + }, + "required": [ + "version", + "recipes" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/public/examples/kochbuch.json b/public/examples/kochbuch.json new file mode 100644 index 0000000..e8d4f43 --- /dev/null +++ b/public/examples/kochbuch.json @@ -0,0 +1,430 @@ +{ + "version": 1, + "recipes": [ + { + "id": "10000000-0000-4000-8000-000000000001", + "title": "Grüne Pesto-Pasta", + "subtitle": "Ein Teller Sommer, ganz ohne Eile.", + "description": "Frisches Basilikum, geröstete Pinienkerne und ein Hauch Zitrone. Unsere liebste Pasta für Tage, an denen es einfach gut sein soll.", + "image": "/images/kitchen.jpg", + "images": [], + "categories": [ + "Alltagsküche", + "Vegetarisch" + ], + "tags": [ + "Schnell", + "Familienliebling" + ], + "cuisine": "Italien", + "difficulty": "Einfach", + "prepMinutes": 10, + "cookMinutes": 12, + "restMinutes": 0, + "servings": 2, + "ingredients": [ + { + "id": "pasta", + "name": "Pasta", + "amount": 200, + "unit": "g", + "section": "Für die Pasta", + "note": "", + "optional": false + }, + { + "id": "basilikum", + "name": "Basilikum", + "amount": 40, + "unit": "g", + "section": "Für das Pesto", + "note": "", + "optional": false + }, + { + "id": "kerne", + "name": "Pinienkerne", + "amount": 30, + "unit": "g", + "section": "Für das Pesto", + "note": "", + "optional": false + }, + { + "id": "oel", + "name": "Olivenöl", + "amount": 60, + "unit": "ml", + "section": "Für das Pesto", + "note": "", + "optional": false + }, + { + "id": "kaese", + "name": "Hartkäse mit mikrobiellem Lab", + "amount": 40, + "unit": "g", + "section": "Für das Pesto", + "note": "", + "optional": false + }, + { + "id": "zitrone", + "name": "Bio-Zitrone", + "amount": 0.5, + "unit": "Stück", + "section": "Zum Abschmecken", + "note": "", + "optional": true + }, + { + "id": "salz", + "name": "Salz", + "amount": null, + "unit": "", + "section": "Zum Abschmecken", + "note": "nach Geschmack", + "optional": false + } + ], + "steps": [ + { + "id": "vorbereiten", + "title": "Ein bisschen Vorbereitung", + "text": "{{basilikum}} waschen und trocken schütteln. {{kaese}} fein reiben. Einen großen Topf Salzwasser aufsetzen.", + "ingredients": [ + "basilikum", + "kaese", + "salz" + ], + "timers": [] + }, + { + "id": "pesto", + "title": "Das Pesto zum Leuchten bringen", + "text": "{{kerne}} in einer trockenen Pfanne goldbraun rösten. Mit Basilikum und {{oel}} fein mixen, anschließend den geriebenen Käse unterheben.", + "ingredients": [ + "kerne", + "basilikum", + "oel", + "kaese" + ], + "timers": [ + { + "id": "roesten", + "label": "Pinienkerne rösten", + "seconds": 180 + } + ] + }, + { + "id": "kochen", + "title": "Pasta trifft Pesto", + "text": "{{pasta}} nach Packungsangabe bissfest kochen. Vor dem Abgießen etwas Kochwasser auffangen. Pasta mit dem Pesto vermengen und mit Kochwasser geschmeidig rühren.", + "ingredients": [ + "pasta" + ], + "timers": [ + { + "id": "nudeln", + "label": "Pasta kochen", + "seconds": 600 + } + ] + }, + { + "id": "geniessen", + "title": "Der schönste Moment", + "text": "Mit dem Saft von {{zitrone}} und Salz abschmecken. Auf warme Teller verteilen und sofort genießen.", + "ingredients": [ + "zitrone", + "salz" + ], + "timers": [] + } + ], + "tips": "Pesto nicht stark erhitzen: So bleibt es leuchtend grün. Ein Schuss stärkehaltiges Nudelwasser macht die Sauce wunderbar cremig.", + "storage": "Pesto in einem sauberen Glas mit einer dünnen Ölschicht bedecken und im Kühlschrank innerhalb von zwei Tagen verbrauchen.", + "nutrition": { + "kcal": 780, + "protein": 22, + "carbs": 79, + "fat": 42 + }, + "published": true + }, + { + "id": "10000000-0000-4000-8000-000000000002", + "title": "Bunte Garten-Bowl", + "subtitle": "Knackig, frisch und voller guter Dinge.", + "description": "Eine großzügige Schüssel Gemüse, cremige Kichererbsen und ein zitroniges Tahini-Dressing. Genau richtig für einen frischen Start.", + "image": "/images/garden.jpg", + "images": [], + "categories": [ + "Vegetarisch" + ], + "tags": [ + "Meal Prep" + ], + "cuisine": "Mediterran", + "difficulty": "Einfach", + "prepMinutes": 20, + "cookMinutes": 10, + "restMinutes": 0, + "servings": 2, + "ingredients": [ + { + "id": "kichererbsen", + "name": "Kichererbsen, gekocht", + "amount": 240, + "unit": "g", + "section": "", + "note": "", + "optional": false + }, + { + "id": "salat", + "name": "Blattsalat", + "amount": 100, + "unit": "g", + "section": "", + "note": "", + "optional": false + }, + { + "id": "tomaten", + "name": "Cherrytomaten", + "amount": 200, + "unit": "g", + "section": "", + "note": "", + "optional": false + }, + { + "id": "gurke", + "name": "Gurke", + "amount": 0.5, + "unit": "Stück", + "section": "", + "note": "", + "optional": false + }, + { + "id": "tahini", + "name": "Tahini", + "amount": 2, + "unit": "EL", + "section": "", + "note": "", + "optional": false + }, + { + "id": "zitrone", + "name": "Zitronensaft", + "amount": 2, + "unit": "EL", + "section": "", + "note": "", + "optional": false + }, + { + "id": "wasser", + "name": "Wasser", + "amount": 3, + "unit": "EL", + "section": "", + "note": "", + "optional": false + }, + { + "id": "salz", + "name": "Salz", + "amount": null, + "unit": "", + "section": "", + "note": "nach Geschmack", + "optional": false + } + ], + "steps": [ + { + "id": "gemuese", + "title": "Bunt vorbereiten", + "text": "{{salat}} waschen und trocknen. {{tomaten}} halbieren und {{gurke}} in Scheiben schneiden.", + "ingredients": [ + "salat", + "tomaten", + "gurke" + ], + "timers": [] + }, + { + "id": "roesten", + "title": "Kichererbsen rösten", + "text": "{{kichererbsen}} abtropfen lassen und in einer beschichteten Pfanne zehn Minuten rösten. Regelmäßig umrühren.", + "ingredients": [ + "kichererbsen" + ], + "timers": [ + { + "id": "roesten", + "label": "Kichererbsen rösten", + "seconds": 600 + } + ] + }, + { + "id": "dressing", + "title": "Alles kommt zusammen", + "text": "{{tahini}} mit {{zitrone}}, {{wasser}} und Salz verrühren. Gemüse und Kichererbsen anrichten und das Dressing darübergeben.", + "ingredients": [ + "tahini", + "zitrone", + "wasser", + "salz" + ], + "timers": [] + } + ], + "tips": "Das Dressing separat aufbewahren, wenn die Bowl für morgen ist.", + "storage": "Gekühlt einen Tag haltbar. Salat und Dressing getrennt lagern.", + "nutrition": null, + "published": true + }, + { + "id": "10000000-0000-4000-8000-000000000003", + "title": "Wärmende Gemüsesuppe", + "subtitle": "Ein Löffel Gemütlichkeit.", + "description": "Saisongemüse, weiße Bohnen und aromatische Kräuter machen aus wenigen Zutaten ein wohliges Essen für die ganze Familie.", + "image": "/images/soup.jpg", + "images": [], + "categories": [ + "Alltagsküche" + ], + "tags": [ + "Familienliebling", + "Meal Prep" + ], + "cuisine": "", + "difficulty": "Einfach", + "prepMinutes": 15, + "cookMinutes": 30, + "restMinutes": 0, + "servings": 4, + "ingredients": [ + { + "id": "karotten", + "name": "Karotten", + "amount": 300, + "unit": "g", + "section": "", + "note": "", + "optional": false + }, + { + "id": "kartoffeln", + "name": "Kartoffeln", + "amount": 400, + "unit": "g", + "section": "", + "note": "", + "optional": false + }, + { + "id": "bohnen", + "name": "Weiße Bohnen, gekocht", + "amount": 240, + "unit": "g", + "section": "", + "note": "", + "optional": false + }, + { + "id": "bruehe", + "name": "Gemüsebrühe", + "amount": 1, + "unit": "l", + "section": "", + "note": "", + "optional": false + }, + { + "id": "zwiebel", + "name": "Zwiebel", + "amount": 1, + "unit": "Stück", + "section": "", + "note": "", + "optional": false + }, + { + "id": "oel", + "name": "Olivenöl", + "amount": 1, + "unit": "EL", + "section": "", + "note": "", + "optional": false + }, + { + "id": "kraeuter", + "name": "Petersilie", + "amount": 0.5, + "unit": "Bund", + "section": "", + "note": "", + "optional": true + } + ], + "steps": [ + { + "id": "schneiden", + "title": "Alles klein schneiden", + "text": "{{karotten}}, {{kartoffeln}} und {{zwiebel}} schälen und würfeln.", + "ingredients": [ + "karotten", + "kartoffeln", + "zwiebel" + ], + "timers": [] + }, + { + "id": "kochen", + "title": "Zeit für Wärme", + "text": "Zwiebel in {{oel}} glasig dünsten. Karotten und Kartoffeln dazugeben, mit {{bruehe}} aufgießen und zugedeckt etwa 25 Minuten sanft kochen, bis das Gemüse weich ist.", + "ingredients": [ + "oel", + "bruehe" + ], + "timers": [ + { + "id": "suppe", + "label": "Gemüse weich kochen", + "seconds": 1500 + } + ] + }, + { + "id": "abschmecken", + "title": "Der letzte Schliff", + "text": "{{bohnen}} dazugeben und fünf Minuten erwärmen. Abschmecken und mit {{kraeuter}} bestreuen.", + "ingredients": [ + "bohnen", + "kraeuter" + ], + "timers": [ + { + "id": "bohnen", + "label": "Bohnen erwärmen", + "seconds": 300 + } + ] + } + ], + "tips": "", + "storage": "Rasch abkühlen und im Kühlschrank maximal zwei Tage lagern. Portionsweise bis zu drei Monate einfrieren.", + "nutrition": null, + "published": true + } + ] +} \ No newline at end of file diff --git a/public/examples/pljeskavica.json b/public/examples/pljeskavica.json new file mode 100644 index 0000000..31d90f8 --- /dev/null +++ b/public/examples/pljeskavica.json @@ -0,0 +1,167 @@ +{ + "version": 1, + "recipes": [ + { + "title": "Serbische Pljeskavica", + "subtitle": "Saftig gegrillt, mit Ajvar und frischen Zwiebeln", + "description": "Ein Klassiker vom Balkan: würzige Faschierte-Laibchen, außen kräftig geröstet und innen saftig. Dazu passen Lepinja, Ajvar und ein knackiger Salat.", + "image": "", + "images": [], + "categories": [ + "Für Gäste" + ], + "tags": [ + "Grillen", + "Familienliebling" + ], + "cuisine": "Serbien", + "difficulty": "Einfach", + "prepMinutes": 20, + "cookMinutes": 12, + "restMinutes": 60, + "servings": 4, + "ingredients": [ + { + "id": "fleisch", + "name": "Rinderfaschiertes", + "amount": 800, + "unit": "g", + "section": "Pljeskavica", + "note": "", + "optional": false + }, + { + "id": "zwiebel", + "name": "Zwiebel", + "amount": 1, + "unit": "Stück", + "section": "Pljeskavica", + "note": "sehr fein gewürfelt", + "optional": false + }, + { + "id": "paprika", + "name": "Paprikapulver edelsüß", + "amount": 2, + "unit": "TL", + "section": "Pljeskavica", + "note": "", + "optional": false + }, + { + "id": "salz", + "name": "Salz", + "amount": 10, + "unit": "g", + "section": "Pljeskavica", + "note": "", + "optional": false + }, + { + "id": "pfeffer", + "name": "Schwarzer Pfeffer", + "amount": null, + "unit": "", + "section": "Pljeskavica", + "note": "nach Geschmack", + "optional": false + }, + { + "id": "wasser", + "name": "Mineralwasser", + "amount": 60, + "unit": "ml", + "section": "Pljeskavica", + "note": "", + "optional": false + }, + { + "id": "ajvar", + "name": "Ajvar", + "amount": 120, + "unit": "g", + "section": "Zum Servieren", + "note": "", + "optional": true + }, + { + "id": "brot", + "name": "Lepinja", + "amount": 4, + "unit": "Stück", + "section": "Zum Servieren", + "note": "", + "optional": false + } + ], + "steps": [ + { + "id": "mischen", + "title": "Die Mischung vorbereiten", + "text": "{{fleisch}}, {{zwiebel}}, {{paprika}}, {{salz}} und {{wasser}} gründlich verkneten. Mit {{pfeffer}} abschmecken.", + "ingredients": [ + "fleisch", + "zwiebel", + "paprika", + "salz", + "wasser", + "pfeffer" + ], + "timers": [] + }, + { + "id": "ruhen", + "title": "Geschmack entwickeln lassen", + "text": "Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.", + "ingredients": [], + "timers": [ + { + "id": "ruhezeit", + "label": "Im Kühlschrank ruhen", + "seconds": 3600 + } + ] + }, + { + "id": "formen", + "title": "Flache Laibchen formen", + "text": "Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken.", + "ingredients": [], + "timers": [] + }, + { + "id": "grillen", + "title": "Kräftig grillen", + "text": "Grill oder Grillpfanne vorheizen. Laibchen von beiden Seiten je etwa fünf bis sechs Minuten braten. Faschiertes vollständig durchgaren: im Kern mindestens 70 °C für zwei Minuten erreichen.", + "ingredients": [], + "timers": [ + { + "id": "erste-seite", + "label": "Erste Seite", + "seconds": 360 + }, + { + "id": "zweite-seite", + "label": "Zweite Seite", + "seconds": 360 + } + ] + }, + { + "id": "servieren", + "title": "Ab an den Tisch", + "text": "In {{brot}} mit {{ajvar}} servieren. Nach Wunsch frische Zwiebelringe dazu reichen.", + "ingredients": [ + "brot", + "ajvar" + ], + "timers": [] + } + ], + "tips": "Nicht während des Bratens flachdrücken – so bleibt die Pljeskavica saftig. Für Schärfe etwas Chiliflocken zur Masse geben.", + "storage": "Reste rasch abkühlen, abgedeckt im Kühlschrank aufbewahren und innerhalb von zwei Tagen verbrauchen. Vor dem Essen gründlich erhitzen.", + "nutrition": null, + "published": false + } + ] +} \ No newline at end of file diff --git a/public/examples/pljeskavica.xml b/public/examples/pljeskavica.xml new file mode 100644 index 0000000..c8bb5f3 --- /dev/null +++ b/public/examples/pljeskavica.xml @@ -0,0 +1,167 @@ + + + + + Serbische Pljeskavica + Saftig gegrillt, mit Ajvar und frischen Zwiebeln + Ein Klassiker vom Balkan: würzige Faschierte-Laibchen, außen kräftig geröstet und innen saftig. Dazu passen Lepinja, Ajvar und ein knackiger Salat. + + + + Für Gäste + + + Grillen + Familienliebling + + Serbien + Einfach + 20 + 12 + 60 + 4 + + + fleisch + Rinderfaschiertes + 800 + g +
Pljeskavica
+ + false +
+ + zwiebel + Zwiebel + 1 + Stück +
Pljeskavica
+ sehr fein gewürfelt + false +
+ + paprika + Paprikapulver edelsüß + 2 + TL +
Pljeskavica
+ + false +
+ + salz + Salz + 10 + g +
Pljeskavica
+ + false +
+ + pfeffer + Schwarzer Pfeffer + + +
Pljeskavica
+ nach Geschmack + false +
+ + wasser + Mineralwasser + 60 + ml +
Pljeskavica
+ + false +
+ + ajvar + Ajvar + 120 + g +
Zum Servieren
+ + true +
+ + brot + Lepinja + 4 + Stück +
Zum Servieren
+ + false +
+
+ + + mischen + Die Mischung vorbereiten + {{fleisch}}, {{zwiebel}}, {{paprika}}, {{salz}} und {{wasser}} gründlich verkneten. Mit {{pfeffer}} abschmecken. + + fleisch + zwiebel + paprika + salz + wasser + pfeffer + + + + + ruhen + Geschmack entwickeln lassen + Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen. + + + + ruhezeit + + 3600 + + + + + formen + Flache Laibchen formen + Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken. + + + + + grillen + Kräftig grillen + Grill oder Grillpfanne vorheizen. Laibchen von beiden Seiten je etwa fünf bis sechs Minuten braten. Faschiertes vollständig durchgaren: im Kern mindestens 70 °C für zwei Minuten erreichen. + + + + erste-seite + + 360 + + + zweite-seite + + 360 + + + + + servieren + Ab an den Tisch + In {{brot}} mit {{ajvar}} servieren. Nach Wunsch frische Zwiebelringe dazu reichen. + + brot + ajvar + + + + + Nicht während des Bratens flachdrücken – so bleibt die Pljeskavica saftig. Für Schärfe etwas Chiliflocken zur Masse geben. + Reste rasch abkühlen, abgedeckt im Kühlschrank aufbewahren und innerhalb von zwei Tagen verbrauchen. Vor dem Essen gründlich erhitzen. + + false +
+
+
diff --git a/scripts/admin.ts b/scripts/admin.ts new file mode 100644 index 0000000..1efbaad --- /dev/null +++ b/scripts/admin.ts @@ -0,0 +1,39 @@ +import 'dotenv/config'; +import { hash } from '@node-rs/argon2'; +import postgres from 'postgres'; +import { createInterface } from 'node:readline/promises'; +const username = process.argv[2]?.trim().toLowerCase(); +if (!username || !/^[a-z0-9._-]{3,80}$/.test(username)) + throw new Error('Aufruf: npm run admin:create -- BENUTZERNAME (3–80 Zeichen)'); +async function passwordInput(): Promise { + if (!process.stdin.isTTY) { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + return input.trimEnd(); + } + process.stdout.write('Neues Passwort (mind. 12 Zeichen, Eingabe verborgen): '); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + // Disable terminal echo while readline receives characters. + const output = rl as unknown as { _writeToOutput: (s: string) => void }; + output._writeToOutput = () => {}; + const value = await rl.question(''); + rl.close(); + process.stdout.write('\n'); + return value; +} +const password = await passwordInput(); +if (password.length < 12 || password.length > 200) + throw new Error('Passwort muss 12–200 Zeichen enthalten.'); +const sql = postgres(process.env.DATABASE_URL!, { max: 1 }); +try { + const hashed = await hash(password, { + algorithm: 2, + memoryCost: 65536, + timeCost: 3, + parallelism: 1, + }); + await sql`INSERT INTO admins (username, password) VALUES (${username}, ${hashed})`; + console.log(`Administrator ${username} erstellt.`); +} finally { + await sql.end(); +} diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..47daaa6 --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,18 @@ +import 'dotenv/config'; +import { readdir, readFile } from 'node:fs/promises'; +import postgres from 'postgres'; +const sql = postgres(process.env.DATABASE_URL!, { max: 1 }); +try { + await sql.begin(async (tx) => { + await tx`SELECT pg_advisory_xact_lock(7218194)`; + await tx`CREATE TABLE IF NOT EXISTS migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`; + for (const name of (await readdir('migrations')).filter((f) => f.endsWith('.sql')).sort()) { + if ((await tx`SELECT name FROM migrations WHERE name = ${name}`).length) continue; + await tx.unsafe(await readFile(`migrations/${name}`, 'utf8')); + await tx`INSERT INTO migrations (name) VALUES (${name})`; + console.log(`Migration angewendet: ${name}`); + } + }); +} finally { + await sql.end(); +} diff --git a/scripts/schema.ts b/scripts/schema.ts new file mode 100644 index 0000000..93356f2 --- /dev/null +++ b/scripts/schema.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { collectionSchema, recipeSchema } from '../src/lib/recipe'; +import { exportRecipes } from '../src/lib/import'; +import { examples } from '../tests/fixtures'; +await mkdir('public/examples', { recursive: true }); +await writeFile( + 'public/examples/hamham.schema.json', + JSON.stringify(z.toJSONSchema(collectionSchema, { target: 'draft-2020-12' }), null, 2), +); +const r = recipeSchema.parse({ + title: 'Serbische Pljeskavica', + subtitle: 'Saftig gegrillt, mit Ajvar und frischen Zwiebeln', + description: + 'Ein Klassiker vom Balkan: würzige Faschierte-Laibchen, außen kräftig geröstet und innen saftig. Dazu passen Lepinja, Ajvar und ein knackiger Salat.', + cuisine: 'Serbien', + categories: ['Für Gäste'], + tags: ['Grillen', 'Familienliebling'], + difficulty: 'Einfach', + prepMinutes: 20, + cookMinutes: 12, + restMinutes: 60, + servings: 4, + published: false, + ingredients: [ + { id: 'fleisch', name: 'Rinderfaschiertes', amount: 800, unit: 'g', section: 'Pljeskavica' }, + { + id: 'zwiebel', + name: 'Zwiebel', + amount: 1, + unit: 'Stück', + note: 'sehr fein gewürfelt', + section: 'Pljeskavica', + }, + { id: 'paprika', name: 'Paprikapulver edelsüß', amount: 2, unit: 'TL', section: 'Pljeskavica' }, + { id: 'salz', name: 'Salz', amount: 10, unit: 'g', section: 'Pljeskavica' }, + { + id: 'pfeffer', + name: 'Schwarzer Pfeffer', + amount: null, + unit: '', + note: 'nach Geschmack', + section: 'Pljeskavica', + }, + { id: 'wasser', name: 'Mineralwasser', amount: 60, unit: 'ml', section: 'Pljeskavica' }, + { + id: 'ajvar', + name: 'Ajvar', + amount: 120, + unit: 'g', + section: 'Zum Servieren', + optional: true, + }, + { id: 'brot', name: 'Lepinja', amount: 4, unit: 'Stück', section: 'Zum Servieren' }, + ], + steps: [ + { + id: 'mischen', + title: 'Die Mischung vorbereiten', + text: '{{fleisch}}, {{zwiebel}}, {{paprika}}, {{salz}} und {{wasser}} gründlich verkneten. Mit {{pfeffer}} abschmecken.', + ingredients: ['fleisch', 'zwiebel', 'paprika', 'salz', 'wasser', 'pfeffer'], + }, + { + id: 'ruhen', + title: 'Geschmack entwickeln lassen', + text: 'Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.', + timers: [{ id: 'ruhezeit', label: 'Im Kühlschrank ruhen', seconds: 3600 }], + }, + { + id: 'formen', + title: 'Flache Laibchen formen', + text: 'Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken.', + }, + { + id: 'grillen', + title: 'Kräftig grillen', + text: 'Grill oder Grillpfanne vorheizen. Laibchen von beiden Seiten je etwa fünf bis sechs Minuten braten. Faschiertes vollständig durchgaren: im Kern mindestens 70 °C für zwei Minuten erreichen.', + timers: [ + { id: 'erste-seite', label: 'Erste Seite', seconds: 360 }, + { id: 'zweite-seite', label: 'Zweite Seite', seconds: 360 }, + ], + }, + { + id: 'servieren', + title: 'Ab an den Tisch', + text: 'In {{brot}} mit {{ajvar}} servieren. Nach Wunsch frische Zwiebelringe dazu reichen.', + ingredients: ['brot', 'ajvar'], + }, + ], + tips: 'Nicht während des Bratens flachdrücken – so bleibt die Pljeskavica saftig. Für Schärfe etwas Chiliflocken zur Masse geben.', + storage: + 'Reste rasch abkühlen, abgedeckt im Kühlschrank aufbewahren und innerhalb von zwei Tagen verbrauchen. Vor dem Essen gründlich erhitzen.', +}); +await writeFile('public/examples/pljeskavica.json', exportRecipes([r], 'json')); +await writeFile('public/examples/pljeskavica.xml', exportRecipes([r], 'xml')); +await writeFile('public/examples/kochbuch.json', exportRecipes(examples, 'json')); +console.log('JSON Schema und vollständige JSON/XML-Beispiele erzeugt.'); diff --git a/src/app/api/[...path]/route.ts b/src/app/api/[...path]/route.ts new file mode 100644 index 0000000..3ebbc1f --- /dev/null +++ b/src/app/api/[...path]/route.ts @@ -0,0 +1,272 @@ +import { cookies } from 'next/headers'; +import { hash, verify } from '@node-rs/argon2'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { db, sql } from '@/db'; +import { admins, sessions, recipes, settings, media } from '@/db/schema'; +import { + admin, + requireAdmin, + checkOrigin, + ApiError, + apiError, + jsonBody, + digest, + newSession, + rateLimit, +} from '@/lib/server'; +import { recipeSchema } from '@/lib/recipe'; +import { getRecipe, listRecipes, saveRecipe, serialize, titleKey } from '@/lib/repository'; +import { parseImport, exportRecipes } from '@/lib/import'; +import { mkdir, writeFile, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +type Context = { params: Promise<{ path: string[] }> }; +const loginSchema = z.object({ + username: z.string().trim().toLowerCase().min(3).max(80), + password: z.string().min(1).max(200), +}); +const uuid = z.string().uuid(); +async function handle(req: Request, ctx: Context) { + try { + const parts = (await ctx.params).path, + route = parts.join('/'), + method = req.method, + url = new URL(req.url); + if (!['GET', 'HEAD'].includes(method)) checkOrigin(req); + if (route === 'health' && method === 'GET') { + await sql`SELECT 1`; + return Response.json({ status: 'ok' }); + } + if (route === 'auth/login' && method === 'POST') { + const body = loginSchema.parse(await jsonBody(req)); + const ip = + process.env.TRUST_PROXY === 'true' + ? (req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown') + : 'direct'; + await rateLimit(`ip:${digest(ip)}`, 50); + await rateLimit(`user:${digest(body.username)}`, 8); + const [user] = await db.select().from(admins).where(eq(admins.username, body.username)); + // Equal expensive work for unknown accounts to reduce timing-based enumeration. + const valid = user + ? await verify(user.password, body.password) + : (await hash(body.password, { memoryCost: 65536, timeCost: 3 }), false); + if (!user || !valid) throw new ApiError(401, 'Benutzername oder Passwort ist falsch.'); + await sql`DELETE FROM sessions WHERE expires < now()`; + await sql`DELETE FROM login_limits WHERE reset < now()`; + await newSession(user.id); + return Response.json({ username: user.username }); + } + if (route === 'auth/me' && method === 'GET') return Response.json({ user: await admin() }); + if (route === 'auth/logout' && method === 'POST') { + const c = await cookies(), + token = c.get('hamham_session')?.value; + if (token) await db.delete(sessions).where(eq(sessions.token, digest(token))); + c.delete('hamham_session'); + return Response.json({ ok: true }); + } + if (route === 'recipes' && method === 'GET') + return Response.json( + await listRecipes(url.searchParams.get('admin') === '1' && !!(await admin())), + ); + if (parts[0] === 'recipes' && parts.length === 2 && method === 'GET') { + const recipe = await getRecipe(uuid.parse(parts[1]), !!(await admin())); + if (!recipe) throw new ApiError(404, 'Rezept nicht gefunden.'); + return Response.json(recipe); + } + const user = await requireAdmin(); + if (route === 'auth/password' && method === 'POST') { + const body = z + .object({ current: z.string().max(200), password: z.string().min(12).max(200) }) + .parse(await jsonBody(req)); + await rateLimit(`password:${user.id}`, 8); + const [row] = await db.select().from(admins).where(eq(admins.id, user.id)); + if (!(await verify(row.password, body.current))) + throw new ApiError(400, 'Aktuelles Passwort ist falsch.'); + const password = await hash(body.password, { + algorithm: 2, + memoryCost: 65536, + timeCost: 3, + parallelism: 1, + }); + await db.transaction(async (tx) => { + await tx.update(admins).set({ password }).where(eq(admins.id, user.id)); + await tx.delete(sessions).where(eq(sessions.adminId, user.id)); + }); + await newSession(user.id); + return Response.json({ ok: true }); + } + if (route === 'recipes' && method === 'POST') + return Response.json(await saveRecipe(recipeSchema.parse(await jsonBody(req))), { + status: 201, + }); + if (parts[0] === 'recipes' && parts.length === 2) { + const id = uuid.parse(parts[1]); + if (method === 'PUT') { + const body = await jsonBody(req); + return Response.json(await saveRecipe(recipeSchema.parse(body), id, body.updatedAt)); + } + if (method === 'DELETE') { + const rows = await db.delete(recipes).where(eq(recipes.id, id)).returning(); + if (!rows.length) throw new ApiError(404, 'Rezept nicht gefunden.'); + return Response.json({ ok: true }); + } + } + if (route === 'import' && method === 'POST') { + const body = z + .object({ raw: z.string(), format: z.enum(['json', 'xml']), preview: z.boolean() }) + .parse(await jsonBody(req)); + let parsed; + try { + parsed = parseImport(body.raw, body.format); + } catch (e) { + if (e instanceof z.ZodError) throw e; + throw new ApiError(422, e instanceof Error ? e.message : 'Ungültiger Import.'); + } + const existing = await listRecipes(true); + const keys = new Set(); + const preview = parsed.recipes.map((r) => { + const key = titleKey(r.title); + const duplicate = + existing.some((e) => e.id === r.id || titleKey(e.title) === key) || keys.has(key); + keys.add(key); + return { ...r, duplicate }; + }); + if (body.preview) return Response.json(preview); + if (preview.some((r) => r.duplicate)) + throw new ApiError( + 409, + 'Duplikate erkannt. Bitte IDs oder Titel prüfen. Es wurde nichts importiert.', + ); + const result = await db.transaction(async (tx) => { + const inserted = []; + for (const r of parsed.recipes) { + const [row] = await tx + .insert(recipes) + .values({ + id: r.id, + title: r.title, + titleKey: titleKey(r.title), + published: r.published, + data: r, + createdAt: r.createdAt ? new Date(r.createdAt) : undefined, + updatedAt: r.updatedAt ? new Date(r.updatedAt) : new Date(), + }) + .returning(); + inserted.push(serialize(row)); + } + return inserted; + }); + return Response.json(result, { status: 201 }); + } + if (route === 'export' && method === 'GET') { + const format = url.searchParams.get('format') === 'xml' ? 'xml' : 'json', + id = url.searchParams.get('id'); + const data = id + ? [await getRecipe(uuid.parse(id), true)].filter((x) => x !== null) + : await listRecipes(true); + return new Response(exportRecipes(data, format), { + headers: { + 'Content-Type': + format === 'xml' ? 'application/xml; charset=utf-8' : 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="hamham-rezepte.${format}"`, + 'Cache-Control': 'no-store', + }, + }); + } + if (route === 'settings') { + if (method === 'GET') + return Response.json( + Object.fromEntries((await db.select().from(settings)).map((r) => [r.key, r.value])), + ); + if (method === 'PUT') { + const body = z + .object({ + categories: z.array(z.string().trim().min(1).max(60)).max(100), + tags: z.array(z.string().trim().min(1).max(60)).max(200), + household: z.string().trim().min(1).max(80), + }) + .parse(await jsonBody(req)); + await db.transaction(async (tx) => { + for (const [key, value] of Object.entries(body)) + await tx + .insert(settings) + .values({ key, value }) + .onConflictDoUpdate({ target: settings.key, set: { value } }); + }); + return Response.json(body); + } + } + if (route === 'media' && method === 'GET') + return Response.json((await db.select().from(media)).map((m) => `/media/${m.name}`)); + if (route === 'media' && method === 'POST') { + const limit = 10 * 1024 * 1024; + if ( + !['image/jpeg', 'image/png', 'image/webp'].includes(req.headers.get('content-type') ?? '') + ) + throw new ApiError(415, 'Bitte JPEG, PNG oder WebP hochladen.'); + const reader = req.body?.getReader(); + if (!reader) throw new ApiError(400, 'Bild fehlt.'); + let size = 0; + const chunks: Uint8Array[] = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > limit) { + await reader.cancel(); + throw new ApiError(413, 'Bild darf höchstens 10 MB groß sein.'); + } + chunks.push(value); + } + let buffer: Buffer; + try { + buffer = await sharp(Buffer.concat(chunks), { limitInputPixels: 40000000 }) + .rotate() + .resize(2400, 2400, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 86 }) + .toBuffer(); + } catch { + throw new ApiError(422, 'Bild konnte nicht sicher gelesen werden.'); + } + const name = `${crypto.randomUUID()}.webp`, + dir = process.env.UPLOAD_DIR ?? './uploads'; + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, name), buffer, { flag: 'wx' }); + try { + await db.insert(media).values({ name }); + } catch (e) { + await unlink(path.join(dir, name)); + throw e; + } + return Response.json({ url: `/media/${name}` }, { status: 201 }); + } + if (route === 'media' && method === 'DELETE') { + const { name } = z + .object({ name: z.string().regex(/^[a-f0-9-]{36}\.webp$/) }) + .parse(await jsonBody(req)); + if ( + (await listRecipes(true)).some( + (r) => r.image === `/media/${name}` || r.images.includes(`/media/${name}`), + ) + ) + throw new ApiError(409, 'Das Bild wird noch von einem Rezept verwendet.'); + await unlink( + path.join(/* turbopackIgnore: true */ process.env.UPLOAD_DIR ?? './uploads', name), + ).catch((e: NodeJS.ErrnoException) => { + if (e.code !== 'ENOENT') throw e; + }); + await db.delete(media).where(eq(media.name, name)); + return Response.json({ ok: true }); + } + throw new ApiError(404, 'Endpunkt nicht gefunden.'); + } catch (e) { + return apiError(e); + } +} +export const GET = handle, + POST = handle, + PUT = handle, + DELETE = handle; diff --git a/src/app/media/[name]/route.ts b/src/app/media/[name]/route.ts new file mode 100644 index 0000000..68ded62 --- /dev/null +++ b/src/app/media/[name]/route.ts @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +export async function GET(_req: Request, ctx: { params: Promise<{ name: string }> }) { + const { name } = await ctx.params; + if (!/^[a-f0-9-]{36}\.webp$/.test(name)) return new Response(null, { status: 404 }); + try { + const buffer = await readFile( + /* turbopackIgnore: true */ path.join( + /* turbopackIgnore: true */ process.env.UPLOAD_DIR ?? './uploads', + name, + ), + ); + return new Response(buffer, { + headers: { + 'Content-Type': 'image/webp', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch { + return new Response(null, { status: 404 }); + } +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..6dd0d39 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,13 @@ +import postgres from 'postgres'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import * as schema from './schema'; +const globalDb = globalThis as unknown as { sql?: ReturnType }; +export const sql = + globalDb.sql ?? + postgres(process.env.DATABASE_URL ?? 'postgres://localhost/hamham', { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); +if (process.env.NODE_ENV !== 'production') globalDb.sql = sql; +export const db = drizzle(sql, { schema }); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..65e9fa6 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,36 @@ +import { pgTable, uuid, text, timestamp, boolean, jsonb, integer } from 'drizzle-orm/pg-core'; +import type { Recipe } from '../lib/recipe'; +export const recipes = pgTable('recipes', { + id: uuid().primaryKey().defaultRandom(), + title: text().notNull(), + titleKey: text('title_key').notNull().unique(), + published: boolean().notNull().default(false), + data: jsonb().$type().notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); +export const admins = pgTable('admins', { + id: uuid().primaryKey().defaultRandom(), + username: text().notNull().unique(), + password: text().notNull(), +}); +export const sessions = pgTable('sessions', { + token: text().primaryKey(), + adminId: uuid('admin_id') + .notNull() + .references(() => admins.id, { onDelete: 'cascade' }), + expires: timestamp({ withTimezone: true }).notNull(), +}); +export const limits = pgTable('login_limits', { + key: text().primaryKey(), + count: integer().notNull(), + reset: timestamp({ withTimezone: true }).notNull(), +}); +export const settings = pgTable('settings', { + key: text().primaryKey(), + value: jsonb().$type().notNull(), +}); +export const media = pgTable('media', { + name: text().primaryKey(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/src/lib/calculator.ts b/src/lib/calculator.ts new file mode 100644 index 0000000..2d6a3e3 --- /dev/null +++ b/src/lib/calculator.ts @@ -0,0 +1,100 @@ +import Decimal from 'decimal.js'; +import type { Ingredient, Recipe } from './recipe'; +Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP }); +export function positive(value: Decimal.Value) { + const n = new Decimal(typeof value === 'string' ? value.replace(',', '.') : value); + if (!n.isFinite() || n.lte(0) || n.gt(1000000000)) + throw new Error('Bitte eine positive, endliche Menge bis 1 Milliarde eingeben.'); + return n; +} +export function portionFactor(original: number, servings: Decimal.Value) { + return positive(positive(servings).div(positive(original))).toString(); +} +const dimensions: Record = { + g: ['mass', 1], + kg: ['mass', 1000], + ml: ['volume', 1], + l: ['volume', 1000], + TL: ['volume', 5], + EL: ['volume', 15], +}; +export function convert(value: Decimal.Value, from: string, to: string) { + if (from === to) return new Decimal(value); + const a = dimensions[from], + b = dimensions[to]; + if (!a || !b || a[0] !== b[0]) throw new Error('Diese Einheiten sind nicht kompatibel.'); + return new Decimal(value).mul(a[1]).div(b[1]); +} +export function ingredientFactor( + ingredient: Ingredient, + value: Decimal.Value, + unit = ingredient.unit, +) { + if (ingredient.amount === null) throw new Error('Diese Zutat hat keine skalierbare Menge.'); + return positive( + convert(positive(value), unit, ingredient.unit).div(positive(ingredient.amount)), + ).toString(); +} +export function scaledAmount(ingredient: Ingredient, factor: Decimal.Value) { + return ingredient.amount === null + ? null + : new Decimal(ingredient.amount).mul(positive(factor)).toString(); +} +export function formatAmount(value: Decimal.Value | null) { + if (value === null) return ''; + const n = new Decimal(value); + const fraction = n.mod(1).toNumber(); + const symbol = fraction === 0.5 ? '½' : fraction === 0.25 ? '¼' : fraction === 0.75 ? '¾' : ''; + if (symbol && n.lt(10)) return `${n.floor().gt(0) ? n.floor().toString() : ''}${symbol}`; + return n.toNumber().toLocaleString('de-AT', { maximumFractionDigits: n.abs().lt(0.01) ? 6 : 2 }); +} +export function ingredientLabel(i: Ingredient, factor: Decimal.Value) { + const value = scaledAmount(i, factor); + return [formatAmount(value), value === null ? '' : i.unit, i.name, i.note] + .filter(Boolean) + .join(' '); +} +export function renderStep(text: string, recipe: Recipe, factor: Decimal.Value) { + return text.replace(/\{\{([^}]+)\}\}/g, (_, id: string) => { + const i = recipe.ingredients.find((i) => i.id === id); + return i ? ingredientLabel(i, factor) : id; + }); +} +export type ShoppingItem = { + id: string; + name: string; + amount: string | null; + unit: string; + checked: boolean; + note: string; +}; +export function mergeShopping(existing: ShoppingItem[], ingredients: Ingredient[], factor: string) { + const items = existing.map((i) => ({ ...i })); + for (const i of ingredients) { + const amount = scaledAmount(i, factor); + const dimension = dimensions[i.unit]; + const unit = dimension ? (dimension[0] === 'mass' ? 'g' : 'ml') : i.unit; + const normalized = amount === null ? null : convert(amount, i.unit, unit); + const same = items.find( + (x) => + x.name.toLocaleLowerCase('de') === i.name.toLocaleLowerCase('de') && + x.unit === unit && + x.note === i.note && + (x.amount === null) === (amount === null), + ); + if (same) { + if (normalized && same.amount) + same.amount = new Decimal(same.amount).plus(normalized).toString(); + same.checked = false; + } else + items.push({ + id: crypto.randomUUID(), + name: i.name, + amount: normalized?.toString() ?? null, + unit, + checked: false, + note: i.note, + }); + } + return items; +} diff --git a/src/lib/cooking.ts b/src/lib/cooking.ts new file mode 100644 index 0000000..53fe53e --- /dev/null +++ b/src/lib/cooking.ts @@ -0,0 +1,21 @@ +export type TimerState = { remaining: number; endAt: number | null; notified?: boolean }; +export function remainingMs(timer: TimerState, now = Date.now()) { + return timer.endAt === null + ? timer.remaining + : Math.max(0, Math.min(timer.remaining, timer.endAt - now)); +} +export function startTimer(timer: TimerState, now = Date.now()): TimerState { + return { remaining: timer.remaining, endAt: now + timer.remaining, notified: false }; +} +export function pauseTimer(timer: TimerState, now = Date.now()): TimerState { + return { remaining: remainingMs(timer, now), endAt: null, notified: timer.notified }; +} +export type CookingProgress = { + revision: string; + step: number; + factor: string; + checked: string[]; + timers: Record; + startedAt: number; + finishedAt: number | null; +}; diff --git a/src/lib/import.ts b/src/lib/import.ts new file mode 100644 index 0000000..7ca7785 --- /dev/null +++ b/src/lib/import.ts @@ -0,0 +1,93 @@ +import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser'; +import { collectionSchema, type Recipe } from './recipe'; +export function parseImport(raw: string, format: 'json' | 'xml') { + if (Buffer.byteLength(raw, 'utf8') > 2 * 1024 * 1024) + throw new Error('Importdatei ist größer als 2 MB.'); + let value: unknown; + if (format === 'json') value = JSON.parse(raw); + else { + if (/ + /\.(recipe|ingredient|step|timer|category|tag|ref)$|\.images\.image$/.test(String(path)), + }); + const root = parser.parse(raw).hamham; + if (!root) throw new Error('XML benötigt das Wurzelelement hamham.'); + const num = (x: unknown, fallback = 0) => (x === undefined || x === '' ? fallback : Number(x)); + const bool = (x: unknown) => x === 'true'; + const decode = (s: string) => + s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); + const decodeTree = (v: unknown): unknown => + typeof v === 'string' + ? decode(v) + : Array.isArray(v) + ? v.map(decodeTree) + : v && typeof v === 'object' + ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, decodeTree(x)])) + : v; + const recipes = (root.recipes?.recipe ?? []).map((r: Record) => ({ + ...r, + servings: num(r.servings), + prepMinutes: num(r.prepMinutes), + cookMinutes: num(r.cookMinutes), + restMinutes: num(r.restMinutes), + published: bool(r.published), + categories: r.categories?.category ?? [], + tags: r.tags?.tag ?? [], + images: r.images?.image ?? [], + ingredients: (r.ingredients?.ingredient ?? []).map((i: Record) => ({ + ...i, + amount: i.amount === '' || i.amount === undefined ? null : Number(i.amount), + optional: bool(i.optional), + })), + steps: (r.steps?.step ?? []).map((s: Record) => ({ + ...s, + ingredients: s.ingredients?.ref ?? [], + timers: (s.timers?.timer ?? []).map((t: Record) => ({ + ...t, + seconds: num(t.seconds), + })), + })), + nutrition: + r.nutrition && typeof r.nutrition === 'object' + ? Object.fromEntries(Object.entries(r.nutrition).map(([k, v]) => [k, num(v)])) + : null, + })); + value = { version: Number(root['@_version']), recipes: decodeTree(recipes) }; + } + return collectionSchema.parse(value); +} +export function exportRecipes(recipes: Recipe[], format: 'json' | 'xml') { + if (format === 'json') return JSON.stringify({ version: 1, recipes }, null, 2); + const data = recipes.map((r) => ({ + ...r, + categories: { category: r.categories }, + tags: { tag: r.tags }, + images: { image: r.images }, + ingredients: { ingredient: r.ingredients.map((i) => ({ ...i, amount: i.amount ?? '' })) }, + steps: { + step: r.steps.map((s) => ({ + ...s, + ingredients: { ref: s.ingredients }, + timers: { timer: s.timers }, + })), + }, + nutrition: r.nutrition ?? '', + })); + return ( + '\n' + + new XMLBuilder({ ignoreAttributes: false, format: true }).build({ + hamham: { '@_version': 1, recipes: { recipe: data } }, + }) + ); +} diff --git a/src/lib/recipe.ts b/src/lib/recipe.ts new file mode 100644 index 0000000..a35491a --- /dev/null +++ b/src/lib/recipe.ts @@ -0,0 +1,137 @@ +import { z } from 'zod'; + +const text = (max: number) => + z + .string() + .trim() + .max(max) + .refine((v) => !/<\/?[a-z][^>]*>/i.test(v), 'HTML ist nicht erlaubt.'); +const id = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/); +export const units = ['g', 'kg', 'ml', 'l', 'Stück', 'TL', 'EL', 'Prise', 'Bund', ''] as const; +export const ingredientSchema = z.object({ + id, + name: text(160).min(1, 'Zutat benötigt einen Namen.'), + amount: z.number().positive().max(1000000).nullable().default(null), + unit: z.enum(units).default(''), + section: text(80).default(''), + note: text(200).default(''), + optional: z.boolean().default(false), +}); +export const stepSchema = z.object({ + id, + title: text(160).default(''), + text: text(6000).min(1, 'Schritt benötigt eine Anleitung.'), + ingredients: z.array(id).max(100).default([]), + timers: z + .array(z.object({ id, label: text(100).min(1), seconds: z.number().int().min(1).max(172800) })) + .max(10) + .default([]), +}); +const image = z + .string() + .regex( + /^\/(?:media\/[a-f0-9-]+\.webp|images\/[a-z0-9-]+\.(?:webp|jpg|svg))$/, + 'Nur lokale Bilder sind erlaubt.', + ); +export const recipeSchema = z + .object({ + id: z.string().uuid().optional(), + title: text(180).min(2, 'Titel: mindestens 2 Zeichen.'), + subtitle: text(200).default(''), + description: text(4000).default(''), + image: image.or(z.literal('')).default(''), + images: z.array(image).max(20).default([]), + categories: z.array(text(60).min(1)).max(20).default([]), + tags: z.array(text(60).min(1)).max(30).default([]), + cuisine: text(80).default(''), + difficulty: z.enum(['Einfach', 'Mittel', 'Anspruchsvoll']).default('Einfach'), + prepMinutes: z.number().int().min(0).max(10080).default(0), + cookMinutes: z.number().int().min(0).max(10080).default(0), + restMinutes: z.number().int().min(0).max(10080).default(0), + servings: z.number().positive().max(10000), + ingredients: z.array(ingredientSchema).min(1).max(200), + steps: z.array(stepSchema).min(1).max(100), + tips: text(6000).default(''), + storage: text(3000).default(''), + nutrition: z + .object({ + kcal: z.number().min(0), + protein: z.number().min(0), + carbs: z.number().min(0), + fat: z.number().min(0), + }) + .nullable() + .default(null), + published: z.boolean().default(false), + createdAt: z.iso.datetime().optional(), + updatedAt: z.iso.datetime().optional(), + }) + .superRefine((r, ctx) => { + const ids = new Set(r.ingredients.map((i) => i.id)); + if (ids.size !== r.ingredients.length) + ctx.addIssue({ + code: 'custom', + path: ['ingredients'], + message: 'Zutaten-IDs müssen eindeutig sein.', + }); + const stepIds = new Set(r.steps.map((s) => s.id)); + if (stepIds.size !== r.steps.length) + ctx.addIssue({ + code: 'custom', + path: ['steps'], + message: 'Schritt-IDs müssen eindeutig sein.', + }); + const timers = r.steps.flatMap((s) => s.timers.map((t) => t.id)); + if (new Set(timers).size !== timers.length) + ctx.addIssue({ + code: 'custom', + path: ['steps'], + message: 'Timer-IDs müssen eindeutig sein.', + }); + r.steps.forEach((s, n) => { + const refs = [ + ...s.ingredients, + ...Array.from(s.text.matchAll(/\{\{([^}]+)\}\}/g), (m) => m[1]), + ]; + if (refs.some((ref) => !ids.has(ref))) + ctx.addIssue({ + code: 'custom', + path: ['steps', n], + message: 'Unbekannte Zutatenreferenz.', + }); + }); + }); +export const collectionSchema = z.object({ + version: z.literal(1), + recipes: z.array(recipeSchema).min(1).max(100), +}); +export type Ingredient = z.infer; +export type Step = z.infer; +export type Recipe = z.infer; +export type SavedRecipe = Recipe & { id: string; createdAt: string; updatedAt: string }; +export const totalMinutes = (r: Recipe) => r.prepMinutes + r.cookMinutes + r.restMinutes; +export function emptyRecipe(): Recipe { + return { + title: '', + subtitle: '', + description: '', + image: '', + images: [], + categories: [], + tags: [], + cuisine: '', + difficulty: 'Einfach', + prepMinutes: 10, + cookMinutes: 20, + restMinutes: 0, + servings: 2, + ingredients: [ + { id: 'i1', name: '', amount: null, unit: 'g', section: '', note: '', optional: false }, + ], + steps: [{ id: 's1', title: '', text: '', ingredients: [], timers: [] }], + tips: '', + storage: '', + nutrition: null, + published: false, + }; +} diff --git a/src/lib/repository.ts b/src/lib/repository.ts new file mode 100644 index 0000000..df59ee7 --- /dev/null +++ b/src/lib/repository.ts @@ -0,0 +1,58 @@ +import { eq, desc, and } from 'drizzle-orm'; +import { db } from '@/db'; +import { recipes } from '@/db/schema'; +import type { Recipe, SavedRecipe } from './recipe'; +import { ApiError } from './server'; +export function serialize(row: typeof recipes.$inferSelect): SavedRecipe { + return { + ...row.data, + id: row.id, + title: row.title, + published: row.published, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} +export async function listRecipes(isAdmin = false) { + return ( + await db + .select() + .from(recipes) + .where(isAdmin ? undefined : eq(recipes.published, true)) + .orderBy(desc(recipes.createdAt)) + ).map(serialize); +} +export async function getRecipe(id: string, isAdmin = false) { + if (!/^[0-9a-f-]{36}$/i.test(id)) return null; + const [row] = await db + .select() + .from(recipes) + .where(and(eq(recipes.id, id), isAdmin ? undefined : eq(recipes.published, true))); + return row ? serialize(row) : null; +} +export const titleKey = (title: string) => title.normalize('NFKC').trim().toLocaleLowerCase('de'); +export async function saveRecipe(data: Recipe, id?: string, expected?: string) { + const values = { + title: data.title, + titleKey: titleKey(data.title), + published: data.published, + data, + updatedAt: new Date(), + }; + if (id) { + if (!expected) throw new ApiError(428, 'Versionsstand zum Speichern fehlt.'); + const [row] = await db + .update(recipes) + .set(values) + .where(and(eq(recipes.id, id), eq(recipes.updatedAt, new Date(expected)))) + .returning(); + if (!row) + throw new ApiError( + 409, + 'Das Rezept wurde zwischenzeitlich verändert. Entwurf sichern und Seite neu laden.', + ); + return serialize(row); + } + const [row] = await db.insert(recipes).values(values).returning(); + return serialize(row); +} diff --git a/src/lib/server.ts b/src/lib/server.ts new file mode 100644 index 0000000..56e7bbc --- /dev/null +++ b/src/lib/server.ts @@ -0,0 +1,96 @@ +import { cookies } from 'next/headers'; +import { createHash, randomBytes } from 'node:crypto'; +import { eq, and, gt } from 'drizzle-orm'; +import { db, sql } from '@/db'; +import { admins, sessions } from '@/db/schema'; +import { ZodError } from 'zod'; +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} +export const digest = (s: string) => createHash('sha256').update(s).digest('hex'); +export async function admin() { + const token = (await cookies()).get('hamham_session')?.value; + if (!token) return null; + const [row] = await db + .select({ id: admins.id, username: admins.username }) + .from(sessions) + .innerJoin(admins, eq(sessions.adminId, admins.id)) + .where(and(eq(sessions.token, digest(token)), gt(sessions.expires, new Date()))); + return row ?? null; +} +export async function requireAdmin() { + const user = await admin(); + if (!user) throw new ApiError(401, 'Bitte als Administrator anmelden.'); + return user; +} +export function checkOrigin(req: Request) { + const expected = process.env.APP_ORIGIN; + if (!expected || req.headers.get('origin') !== new URL(expected).origin) + throw new ApiError(403, 'Anfrage von unbekannter Herkunft abgelehnt.'); +} +export async function bodyText(req: Request, max = 2 * 1024 * 1024) { + if (Number(req.headers.get('content-length') ?? 0) > max) + throw new ApiError(413, 'Datei oder Anfrage ist zu groß.'); + const reader = req.body?.getReader(); + if (!reader) return ''; + let size = 0; + const parts: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > max) { + await reader.cancel(); + throw new ApiError(413, 'Datei oder Anfrage ist zu groß.'); + } + parts.push(value); + } + return Buffer.concat(parts).toString('utf8'); +} +export async function jsonBody(req: Request) { + try { + return JSON.parse(await bodyText(req)); + } catch (e) { + if (e instanceof ApiError) throw e; + throw new ApiError(400, 'Ungültige JSON-Anfrage.'); + } +} +export async function newSession(adminId: string) { + const token = randomBytes(32).toString('hex'); + await db + .insert(sessions) + .values({ token: digest(token), adminId, expires: new Date(Date.now() + 7 * 86400000) }); + (await cookies()).set('hamham_session', token, { + httpOnly: true, + secure: process.env.COOKIE_SECURE === 'true', + sameSite: 'strict', + path: '/', + maxAge: 7 * 86400, + }); +} +export async function rateLimit(key: string, max: number) { + const [row] = + await sql`INSERT INTO login_limits (key, count, reset) VALUES (${key}, 1, now() + interval '15 minutes') ON CONFLICT (key) DO UPDATE SET count = CASE WHEN login_limits.reset < now() THEN 1 ELSE login_limits.count + 1 END, reset = CASE WHEN login_limits.reset < now() THEN now() + interval '15 minutes' ELSE login_limits.reset END RETURNING count`; + if (row.count > max) + throw new ApiError(429, 'Zu viele Versuche. Bitte in 15 Minuten erneut versuchen.'); +} +export function apiError(e: unknown) { + if (e instanceof ApiError) return Response.json({ error: e.message }, { status: e.status }); + if (e instanceof ZodError) + return Response.json( + { error: e.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n') }, + { status: 422 }, + ); + if ((e as { code?: string })?.code === '23505') + return Response.json( + { error: 'Ein Rezept oder Eintrag mit diesem Namen existiert bereits.' }, + { status: 409 }, + ); + console.error('API error', e); + return Response.json({ error: 'Die Anfrage konnte nicht verarbeitet werden.' }, { status: 500 }); +} diff --git a/tests/fixtures.ts b/tests/fixtures.ts new file mode 100644 index 0000000..382d798 --- /dev/null +++ b/tests/fixtures.ts @@ -0,0 +1,173 @@ +import { recipeSchema } from '../src/lib/recipe'; +export const pasta = recipeSchema.parse({ + id: '10000000-0000-4000-8000-000000000001', + title: 'Grüne Pesto-Pasta', + subtitle: 'Ein Teller Sommer, ganz ohne Eile.', + description: + 'Frisches Basilikum, geröstete Pinienkerne und ein Hauch Zitrone. Unsere liebste Pasta für Tage, an denen es einfach gut sein soll.', + image: '/images/kitchen.jpg', + categories: ['Alltagsküche', 'Vegetarisch'], + tags: ['Schnell', 'Familienliebling'], + cuisine: 'Italien', + servings: 2, + prepMinutes: 10, + cookMinutes: 12, + published: true, + ingredients: [ + { id: 'pasta', name: 'Pasta', amount: 200, unit: 'g', section: 'Für die Pasta' }, + { id: 'basilikum', name: 'Basilikum', amount: 40, unit: 'g', section: 'Für das Pesto' }, + { id: 'kerne', name: 'Pinienkerne', amount: 30, unit: 'g', section: 'Für das Pesto' }, + { id: 'oel', name: 'Olivenöl', amount: 60, unit: 'ml', section: 'Für das Pesto' }, + { + id: 'kaese', + name: 'Hartkäse mit mikrobiellem Lab', + amount: 40, + unit: 'g', + section: 'Für das Pesto', + }, + { + id: 'zitrone', + name: 'Bio-Zitrone', + amount: 0.5, + unit: 'Stück', + section: 'Zum Abschmecken', + optional: true, + }, + { + id: 'salz', + name: 'Salz', + amount: null, + unit: '', + note: 'nach Geschmack', + section: 'Zum Abschmecken', + }, + ], + steps: [ + { + id: 'vorbereiten', + title: 'Ein bisschen Vorbereitung', + text: '{{basilikum}} waschen und trocken schütteln. {{kaese}} fein reiben. Einen großen Topf Salzwasser aufsetzen.', + ingredients: ['basilikum', 'kaese', 'salz'], + }, + { + id: 'pesto', + title: 'Das Pesto zum Leuchten bringen', + text: '{{kerne}} in einer trockenen Pfanne goldbraun rösten. Mit Basilikum und {{oel}} fein mixen, anschließend den geriebenen Käse unterheben.', + ingredients: ['kerne', 'basilikum', 'oel', 'kaese'], + timers: [{ id: 'roesten', label: 'Pinienkerne rösten', seconds: 180 }], + }, + { + id: 'kochen', + title: 'Pasta trifft Pesto', + text: '{{pasta}} nach Packungsangabe bissfest kochen. Vor dem Abgießen etwas Kochwasser auffangen. Pasta mit dem Pesto vermengen und mit Kochwasser geschmeidig rühren.', + ingredients: ['pasta'], + timers: [{ id: 'nudeln', label: 'Pasta kochen', seconds: 600 }], + }, + { + id: 'geniessen', + title: 'Der schönste Moment', + text: 'Mit dem Saft von {{zitrone}} und Salz abschmecken. Auf warme Teller verteilen und sofort genießen.', + ingredients: ['zitrone', 'salz'], + }, + ], + tips: 'Pesto nicht stark erhitzen: So bleibt es leuchtend grün. Ein Schuss stärkehaltiges Nudelwasser macht die Sauce wunderbar cremig.', + storage: + 'Pesto in einem sauberen Glas mit einer dünnen Ölschicht bedecken und im Kühlschrank innerhalb von zwei Tagen verbrauchen.', + nutrition: { kcal: 780, protein: 22, carbs: 79, fat: 42 }, +}); +export const salad = recipeSchema.parse({ + id: '10000000-0000-4000-8000-000000000002', + title: 'Bunte Garten-Bowl', + subtitle: 'Knackig, frisch und voller guter Dinge.', + description: + 'Eine großzügige Schüssel Gemüse, cremige Kichererbsen und ein zitroniges Tahini-Dressing. Genau richtig für einen frischen Start.', + image: '/images/garden.jpg', + categories: ['Vegetarisch'], + tags: ['Meal Prep'], + cuisine: 'Mediterran', + servings: 2, + prepMinutes: 20, + cookMinutes: 10, + published: true, + ingredients: [ + { id: 'kichererbsen', name: 'Kichererbsen, gekocht', amount: 240, unit: 'g' }, + { id: 'salat', name: 'Blattsalat', amount: 100, unit: 'g' }, + { id: 'tomaten', name: 'Cherrytomaten', amount: 200, unit: 'g' }, + { id: 'gurke', name: 'Gurke', amount: 0.5, unit: 'Stück' }, + { id: 'tahini', name: 'Tahini', amount: 2, unit: 'EL' }, + { id: 'zitrone', name: 'Zitronensaft', amount: 2, unit: 'EL' }, + { id: 'wasser', name: 'Wasser', amount: 3, unit: 'EL' }, + { id: 'salz', name: 'Salz', amount: null, note: 'nach Geschmack' }, + ], + steps: [ + { + id: 'gemuese', + title: 'Bunt vorbereiten', + text: '{{salat}} waschen und trocknen. {{tomaten}} halbieren und {{gurke}} in Scheiben schneiden.', + ingredients: ['salat', 'tomaten', 'gurke'], + }, + { + id: 'roesten', + title: 'Kichererbsen rösten', + text: '{{kichererbsen}} abtropfen lassen und in einer beschichteten Pfanne zehn Minuten rösten. Regelmäßig umrühren.', + ingredients: ['kichererbsen'], + timers: [{ id: 'roesten', label: 'Kichererbsen rösten', seconds: 600 }], + }, + { + id: 'dressing', + title: 'Alles kommt zusammen', + text: '{{tahini}} mit {{zitrone}}, {{wasser}} und Salz verrühren. Gemüse und Kichererbsen anrichten und das Dressing darübergeben.', + ingredients: ['tahini', 'zitrone', 'wasser', 'salz'], + }, + ], + tips: 'Das Dressing separat aufbewahren, wenn die Bowl für morgen ist.', + storage: 'Gekühlt einen Tag haltbar. Salat und Dressing getrennt lagern.', +}); +export const soup = recipeSchema.parse({ + id: '10000000-0000-4000-8000-000000000003', + title: 'Wärmende Gemüsesuppe', + subtitle: 'Ein Löffel Gemütlichkeit.', + description: + 'Saisongemüse, weiße Bohnen und aromatische Kräuter machen aus wenigen Zutaten ein wohliges Essen für die ganze Familie.', + image: '/images/soup.jpg', + categories: ['Alltagsküche'], + tags: ['Familienliebling', 'Meal Prep'], + servings: 4, + prepMinutes: 15, + cookMinutes: 30, + published: true, + ingredients: [ + { id: 'karotten', name: 'Karotten', amount: 300, unit: 'g' }, + { id: 'kartoffeln', name: 'Kartoffeln', amount: 400, unit: 'g' }, + { id: 'bohnen', name: 'Weiße Bohnen, gekocht', amount: 240, unit: 'g' }, + { id: 'bruehe', name: 'Gemüsebrühe', amount: 1, unit: 'l' }, + { id: 'zwiebel', name: 'Zwiebel', amount: 1, unit: 'Stück' }, + { id: 'oel', name: 'Olivenöl', amount: 1, unit: 'EL' }, + { id: 'kraeuter', name: 'Petersilie', amount: 0.5, unit: 'Bund', optional: true }, + ], + steps: [ + { + id: 'schneiden', + title: 'Alles klein schneiden', + text: '{{karotten}}, {{kartoffeln}} und {{zwiebel}} schälen und würfeln.', + ingredients: ['karotten', 'kartoffeln', 'zwiebel'], + }, + { + id: 'kochen', + title: 'Zeit für Wärme', + text: 'Zwiebel in {{oel}} glasig dünsten. Karotten und Kartoffeln dazugeben, mit {{bruehe}} aufgießen und zugedeckt etwa 25 Minuten sanft kochen, bis das Gemüse weich ist.', + ingredients: ['oel', 'bruehe'], + timers: [{ id: 'suppe', label: 'Gemüse weich kochen', seconds: 1500 }], + }, + { + id: 'abschmecken', + title: 'Der letzte Schliff', + text: '{{bohnen}} dazugeben und fünf Minuten erwärmen. Abschmecken und mit {{kraeuter}} bestreuen.', + ingredients: ['bohnen', 'kraeuter'], + timers: [{ id: 'bohnen', label: 'Bohnen erwärmen', seconds: 300 }], + }, + ], + storage: + 'Rasch abkühlen und im Kühlschrank maximal zwei Tage lagern. Portionsweise bis zu drei Monate einfrieren.', +}); +export const examples = [pasta, salad, soup]; diff --git a/tests/unit/calculator.test.ts b/tests/unit/calculator.test.ts new file mode 100644 index 0000000..1cfbe63 --- /dev/null +++ b/tests/unit/calculator.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { ingredientSchema } from '../../src/lib/recipe'; +import { + positive, + portionFactor, + ingredientFactor, + scaledAmount, + formatAmount, + convert, + renderStep, + mergeShopping, +} from '../../src/lib/calculator'; +import { pasta } from '../fixtures'; +const chicken = ingredientSchema.parse({ + id: 'chicken', + name: 'Hühnerbrust', + amount: 400, + unit: 'g', +}); +describe('Precise scaling from immutable originals', () => { + it('scales 2 to 8 portions', () => { + const f = portionFactor(2, 8); + expect(f).toBe('4'); + expect(scaledAmount(chicken, f)).toBe('1600'); + }); + it('scales all ingredients from 400 g to 800 g', () => { + const f = ingredientFactor(chicken, 800); + expect(f).toBe('2'); + expect(scaledAmount({ ...chicken, amount: 200 }, f)).toBe('400'); + expect(scaledAmount({ ...chicken, amount: 50 }, f)).toBe('100'); + }); + it('supports downscaling and comma decimals', () => { + expect(ingredientFactor(chicken, '0,2', 'kg')).toBe('0.5'); + expect(portionFactor(2, '1,5')).toBe('0.75'); + }); + it('normalizes kg, liters and spoons without conflating mass with volume', () => { + expect(convert(1.5, 'kg', 'g').toString()).toBe('1500'); + expect(convert(250, 'ml', 'l').toString()).toBe('0.25'); + expect(convert(2, 'EL', 'TL').toString()).toBe('6'); + expect(() => convert(2, 'g', 'ml')).toThrow(); + }); + it('scales optional ingredients and leaves unquantified ingredients unchanged', () => { + expect(scaledAmount({ ...chicken, optional: true }, 2)).toBe('800'); + expect(scaledAmount({ ...chicken, amount: null }, 2)).toBeNull(); + expect(() => ingredientFactor({ ...chicken, amount: null }, 3)).toThrow(); + }); + it.each([0, -1, 'NaN', 'Infinity', '', 'abc', 1000000001])('rejects invalid amounts: %s', (v) => { + expect(() => positive(v)).toThrow(); + }); + it('prevents division by zero', () => { + expect(() => ingredientFactor({ ...chicken, amount: 0 }, 1)).toThrow(); + expect(() => portionFactor(0, 4)).toThrow(); + }); + it('rejects extreme scale factors before they reach the interface', () => { + expect(() => ingredientFactor({ ...chicken, amount: 0.000001 }, 1000000)).toThrow(); + expect(() => portionFactor(0.000001, 1000000)).toThrow(); + }); + it('keeps exact quantities despite repeated rescaling', () => { + for (let n = 1; n <= 100; n++) { + expect(scaledAmount(chicken, ingredientFactor(chicken, n))).toBe(String(n)); + } + expect(scaledAmount(chicken, '1')).toBe('400'); + expect(scaledAmount({ ...chicken, amount: 0.1 }, 3)).toBe('0.3'); + }); + it('uses readable fractions and final display rounding', () => { + expect(formatAmount(0.5)).toBe('½'); + expect(formatAmount(1.25)).toBe('1¼'); + expect(formatAmount(2.75)).toBe('2¾'); + expect(formatAmount(10.6666)).toBe('10,67'); + expect(formatAmount(null)).toBe(''); + }); + it('updates only structured references in instructions', () => { + expect(renderStep('{{pasta}} für 10 Minuten kochen.', pasta, '2')).toBe( + '400 g Pasta für 10 Minuten kochen.', + ); + }); + it('merges compatible shopping quantities across recipes', () => { + const list = mergeShopping([], [chicken], '2'); + const result = mergeShopping(list, [{ ...chicken, amount: 0.5, unit: 'kg' }], '1'); + expect(result).toHaveLength(1); + expect(result[0].amount).toBe('1300'); + expect(list[0].amount).toBe('800'); + }); + it('keeps incompatible shopping units and notes separate', () => { + const a = mergeShopping([], [chicken], '1'); + const b = mergeShopping( + a, + [ + { ...chicken, unit: 'Stück' }, + { ...chicken, note: 'gekocht' }, + ], + '1', + ); + expect(b).toHaveLength(3); + }); +}); diff --git a/tests/unit/cooking.test.ts b/tests/unit/cooking.test.ts new file mode 100644 index 0000000..f4bb499 --- /dev/null +++ b/tests/unit/cooking.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { startTimer, pauseTimer, remainingMs } from '../../src/lib/cooking'; +describe('Timestamp-based kitchen timers', () => { + it('does not display an extra second when the last render predates the start', () => { + expect(remainingMs(startTimer({ remaining: 60000, endAt: null }, 1200), 1000)).toBe(60000); + }); + it('starts with an absolute deadline, not ticking state', () => { + expect(startTimer({ remaining: 60000, endAt: null }, 1000).endAt).toBe(61000); + }); + it('survives navigation, sleep and serialized progress', () => { + const timer = JSON.parse(JSON.stringify(startTimer({ remaining: 60000, endAt: null }, 1000))); + expect(remainingMs(timer, 41000)).toBe(20000); + expect(remainingMs(timer, 90000)).toBe(0); + }); + it('pauses precisely and resumes without drift', () => { + const paused = pauseTimer(startTimer({ remaining: 60000, endAt: null }, 1000), 21000); + expect(remainingMs(paused, 90000)).toBe(40000); + expect(remainingMs(startTimer(paused, 90000), 100000)).toBe(30000); + }); +}); diff --git a/tests/unit/import.test.ts b/tests/unit/import.test.ts new file mode 100644 index 0000000..d2c7eb8 --- /dev/null +++ b/tests/unit/import.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { parseImport, exportRecipes } from '../../src/lib/import'; +import { recipeSchema } from '../../src/lib/recipe'; +import { examples, pasta } from '../fixtures'; +describe('Versioned, safe, lossless recipe interchange', () => { + it.each(['json', 'xml'] as const)('roundtrips all fields in %s', (format) => { + expect(parseImport(exportRecipes(examples, format), format)).toEqual({ + version: 1, + recipes: examples, + }); + }); + it('preserves XML escaped punctuation, unicode and literal ampersands', () => { + const r = { + ...pasta, + title: 'Öl & Gemüse', + tips: 'Mit "Liebe" & Geduld. 1 < 2. & bleibt Text.', + }; + expect(parseImport(exportRecipes([r], 'xml'), 'xml').recipes[0]).toEqual(r); + }); + it('rejects unknown versions and missing mandatory fields', () => { + expect(() => parseImport('{"version":2,"recipes":[]}', 'json')).toThrow(); + expect(() => parseImport('{"version":1,"recipes":[{"title":"Test"}]}', 'json')).toThrow(); + }); + it('rejects HTML payloads, invalid image paths and broken references', () => { + expect(() => recipeSchema.parse({ ...pasta, title: '' })).toThrow(); + expect(() => recipeSchema.parse({ ...pasta, image: '/media/../../etc/passwd' })).toThrow(); + expect(() => + recipeSchema.parse({ ...pasta, steps: [{ ...pasta.steps[0], text: '{{unknown}}' }] }), + ).toThrow(); + }); + it('rejects duplicate ingredient, step and timer IDs', () => { + expect(() => + recipeSchema.parse({ ...pasta, ingredients: [...pasta.ingredients, pasta.ingredients[0]] }), + ).toThrow(); + expect(() => + recipeSchema.parse({ ...pasta, steps: [pasta.steps[1], pasta.steps[1]] }), + ).toThrow(); + }); + it.each([ + ']>&a;', + ']>&a;', + '', + ])('rejects unsafe or malformed XML', (raw) => { + expect(() => parseImport(raw, 'xml')).toThrow(); + }); + it('enforces byte limits before parsing', () => { + expect(() => parseImport('x'.repeat(2 * 1024 * 1024 + 1), 'json')).toThrow(/2 MB/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1feabd7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "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"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..97034c7 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { include: ['tests/unit/**/*.test.ts'] } });