added intelligent ingredient parsing and updated form look. changed native form submission to spa style json api

This commit is contained in:
taogaetz 2025-09-02 13:27:37 -04:00
parent 2a577cf6ab
commit f5c4a5e008
6 changed files with 750 additions and 100 deletions

226
package-lock.json generated
View File

@ -9,7 +9,8 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@prisma/client": "^6.14.0", "@prisma/client": "^6.14.0",
"@prisma/extension-accelerate": "^2.0.2" "@prisma/extension-accelerate": "^2.0.2",
"convert": "^5.12.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
@ -815,7 +816,6 @@
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "2.0.5", "@nodelib/fs.stat": "2.0.5",
@ -829,7 +829,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@ -839,7 +838,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.scandir": "2.1.5", "@nodelib/fs.scandir": "2.1.5",
@ -1956,6 +1954,31 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -1990,6 +2013,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@ -2005,7 +2040,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@ -2160,6 +2194,15 @@
"node": "^14.18.0 || >=16.10.0" "node": "^14.18.0 || >=16.10.0"
} }
}, },
"node_modules/convert": {
"version": "5.12.0",
"resolved": "https://registry.npmjs.org/convert/-/convert-5.12.0.tgz",
"integrity": "sha512-gwi1eN0dZsVI//Z+VHPgosbV0u3vxCgPT5p9H3pK5RyGrHrKaQSUNKxoGTYFbkJWv+qYaGY4w394gDNNaXer/g==",
"license": "MIT",
"dependencies": {
"wireit": "0.14.12"
}
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@ -2650,7 +2693,6 @@
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.stat": "^2.0.2",
@ -2667,7 +2709,6 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
@ -2694,7 +2735,6 @@
"version": "1.19.1", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"reusify": "^1.0.4" "reusify": "^1.0.4"
@ -2732,7 +2772,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@ -2783,7 +2822,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -2855,7 +2893,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphemer": { "node_modules/graphemer": {
@ -2912,11 +2949,22 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -2926,7 +2974,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@ -2939,7 +2986,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@ -3006,6 +3052,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -3340,7 +3392,6 @@
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@ -3350,7 +3401,6 @@
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
@ -3364,7 +3414,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@ -3485,6 +3534,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz",
@ -3928,6 +3986,17 @@
} }
} }
}, },
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -3959,7 +4028,6 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -4021,11 +4089,19 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"iojs": ">=1.0.0", "iojs": ">=1.0.0",
@ -4076,7 +4152,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -4152,6 +4227,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/sirv": { "node_modules/sirv": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
@ -4345,7 +4426,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
@ -4585,6 +4665,110 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/wireit": {
"version": "0.14.12",
"resolved": "https://registry.npmjs.org/wireit/-/wireit-0.14.12.tgz",
"integrity": "sha512-gNSd+nZmMo6cuICezYXRIayu6TSOeCSCDzjSF0q6g8FKDsRbdqrONrSZYzdk/uBISmRcv4vZtsno6GyGvdXwGA==",
"license": "Apache-2.0",
"workspaces": [
"vscode-extension",
"website"
],
"dependencies": {
"brace-expansion": "^4.0.0",
"chokidar": "^3.5.3",
"fast-glob": "^3.2.11",
"jsonc-parser": "^3.0.0",
"proper-lockfile": "^4.1.2"
},
"bin": {
"wireit": "bin/wireit.js"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/wireit/node_modules/balanced-match": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-3.0.1.tgz",
"integrity": "sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w==",
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/wireit/node_modules/brace-expansion": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-4.0.1.tgz",
"integrity": "sha512-YClrbvTCXGe70pU2JiEiPLYXO9gQkyxYeKpJIQHVS/gOs6EWMQP2RYBwjFLNT322Ji8TOC3IMPfsYCedNpzKfA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^3.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/wireit/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/wireit/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/wireit/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/wireit/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

@ -43,6 +43,7 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.14.0", "@prisma/client": "^6.14.0",
"@prisma/extension-accelerate": "^2.0.2" "@prisma/extension-accelerate": "^2.0.2",
"convert": "^5.12.0"
} }
} }

View File

@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
const { recipe } = data; const { recipe } = data;
let scale = $state(1);
function formatQuantity(q: number | null | undefined): string {
if (q == null) return '';
const scaled = q * scale;
return Number(scaled.toFixed(2)).toString();
}
function goBack() { function goBack() {
goto('/'); goto('/');
@ -20,7 +26,7 @@
<header class="border-b border-base-300 bg-base-100/90 p-5 backdrop-blur-md"> <header class="border-b border-base-300 bg-base-100/90 p-5 backdrop-blur-md">
<button <button
class="btn gap-2 text-base-content/70 btn-ghost hover:bg-base-200 hover:text-base-content" class="btn gap-2 text-base-content/70 btn-ghost hover:bg-base-200 hover:text-base-content"
on:click={goBack} onclick={goBack}
> >
<svg <svg
width="20" width="20"
@ -104,6 +110,18 @@
</svg> </svg>
<span class="font-semibold">{recipe.station}</span> <span class="font-semibold">{recipe.station}</span>
</div> </div>
<div class="flex items-center gap-3">
<label class="font-semibold text-base-content/70">Scale</label>
<input
type="range"
min="0.25"
max="4"
step="0.25"
bind:value={scale}
class="range range-primary"
/>
<span class="badge">{scale}×</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -122,7 +140,7 @@
</span> </span>
{#if recipeIngredient.quantity || recipeIngredient.unit} {#if recipeIngredient.quantity || recipeIngredient.unit}
<span class="font-medium text-base-content/70"> <span class="font-medium text-base-content/70">
{recipeIngredient.quantity} {formatQuantity(recipeIngredient.quantity)}
{recipeIngredient.unit} {recipeIngredient.unit}
</span> </span>
{/if} {/if}

View File

@ -1,60 +1,65 @@
import type { Actions } from './$types'; // import type { Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit'; // import { fail } from '@sveltejs/kit';
import prisma from '$lib/server/prisma'; // import prisma from '$lib/server/prisma';
export const actions: Actions = { // export const actions: Actions = {
create: async ({ request }) => { // create: async ({ request }) => {
const formData = await request.formData(); // const formData = await request.formData();
const name = (formData.get('name') as string | null)?.trim() ?? ''; // const name = (formData.get('name') as string | null)?.trim() ?? '';
const description = (formData.get('description') as string | null)?.trim() || null; // const description = (formData.get('description') as string | null)?.trim() || null;
const instructions = (formData.get('instructions') as string | null)?.trim() || null; // const instructions = (formData.get('instructions') as string | null)?.trim() || null;
const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string; // const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string;
const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string; // const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string;
const ingredientsRaw = (formData.get('ingredients') as string | null) || ''; // const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
// let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: string | null }> = [];
if (!name) { // if (!name) {
return fail(400, { message: 'Name is required', name, description, instructions, time, station, ingredients: ingredientsRaw }); // return fail(400, { message: 'Name is required', name, description, instructions, time, station });
} // }
const recipe = await prisma.recipe.create({ // // Parse the ingredients JSON from client
data: { // if (parsedIngredientsRaw) {
name, // try {
description, // parsedIngredients = JSON.parse(parsedIngredientsRaw);
instructions, // } catch (error) {
time, // console.error('Failed to parse ingredients JSON:', error);
station // return fail(400, { message: 'Invalid ingredients format', name, description, instructions, time, station });
} // }
}); // }
const ingredients = Array.from( // const recipe = await prisma.recipe.create({
new Set( // data: {
ingredientsRaw // name,
.split(/\r?\n/) // description,
.map((l) => l.trim()) // instructions,
.filter((l) => l.length > 0) // time,
) // station
); // }
// });
if (ingredients.length > 0) { // if (parsedIngredients.length > 0) {
for (const ingredientName of ingredients) { // for (const parsed of parsedIngredients) {
const ingredient = await prisma.ingredient.upsert({ // if (!parsed.name) continue;
where: { name: ingredientName }, // const ingredient = await prisma.ingredient.upsert({
update: {}, // where: { name: parsed.name },
create: { name: ingredientName } // update: {},
}); // create: { name: parsed.name }
// });
// await prisma.recipeIngredient.create({
// data: {
// recipeId: recipe.id,
// ingredientId: ingredient.id,
// quantity: parsed.quantity,
// unit: parsed.unit,
// prep: parsed.prep
// }
// });
// }
// }
await prisma.recipeIngredient.create({ // return { location: `/recipe/${recipe.id}` };
data: { // }
recipeId: recipe.id, // };
ingredientId: ingredient.id
}
});
}
}
throw redirect(303, `/recipe/${recipe.id}`);
}
};

View File

@ -1,45 +1,339 @@
<script lang="ts"> <script lang="ts">
let pending = $state(false); let pending = $state(false);
let lastError = $state<string | null>(null); let lastError = $state<string | null>(null);
let lastSuccess = $state<string | null>(null);
let ingredientsText = $state('');
let formSubmitted = $state(false);
// Parsing functions for live preview
function parseMixedNumber(input: string): number | null {
const trimmed = input.trim();
if (!trimmed) return null;
// e.g. 1 1/2, 3/4, 2.5
const parts = trimmed.split(/\s+/);
let total = 0;
for (const part of parts) {
if (/^\d+\/\d+$/.test(part)) {
const [num, den] = part.split('/').map(Number);
if (den === 0) return null;
total += num / den;
} else if (/^\d+(?:\.\d+)?$/.test(part)) {
total += Number(part);
} else {
// unexpected token
return null;
}
}
return total;
}
const unitAliases: Record<string, { canonical: string; system: 'volume' | 'mass' | 'count' }> = {
// volume
tsp: { canonical: 'ml', system: 'volume' },
'tsp.': { canonical: 'ml', system: 'volume' },
teaspoon: { canonical: 'ml', system: 'volume' },
teaspoons: { canonical: 'ml', system: 'volume' },
tbsp: { canonical: 'ml', system: 'volume' },
'tbsp.': { canonical: 'ml', system: 'volume' },
tablespoon: { canonical: 'ml', system: 'volume' },
tablespoons: { canonical: 'ml', system: 'volume' },
cup: { canonical: 'ml', system: 'volume' },
cups: { canonical: 'ml', system: 'volume' },
ml: { canonical: 'ml', system: 'volume' },
milliliter: { canonical: 'ml', system: 'volume' },
milliliters: { canonical: 'ml', system: 'volume' },
l: { canonical: 'ml', system: 'volume' },
liter: { canonical: 'ml', system: 'volume' },
liters: { canonical: 'ml', system: 'volume' },
pt: { canonical: 'ml', system: 'volume' },
qt: { canonical: 'ml', system: 'volume' },
gal: { canonical: 'ml', system: 'volume' },
floz: { canonical: 'ml', system: 'volume' },
'fl-oz': { canonical: 'ml', system: 'volume' },
'fl oz': { canonical: 'ml', system: 'volume' },
// mass
g: { canonical: 'g', system: 'mass' },
gram: { canonical: 'g', system: 'mass' },
grams: { canonical: 'g', system: 'mass' },
kg: { canonical: 'g', system: 'mass' },
kilogram: { canonical: 'g', system: 'mass' },
kilograms: { canonical: 'g', system: 'mass' },
oz: { canonical: 'g', system: 'mass' },
ounce: { canonical: 'g', system: 'mass' },
ounces: { canonical: 'g', system: 'mass' },
lb: { canonical: 'g', system: 'mass' },
lbs: { canonical: 'g', system: 'mass' },
pound: { canonical: 'g', system: 'mass' },
pounds: { canonical: 'g', system: 'mass' },
// count / generic pieces stay as-is
clove: { canonical: 'clove', system: 'count' },
cloves: { canonical: 'clove', system: 'count' },
piece: { canonical: 'pc', system: 'count' },
pieces: { canonical: 'pc', system: 'count' },
pc: { canonical: 'pc', system: 'count' }
};
function parseIngredientLine(
line: string
): { name: string; quantity: number | null; unit: string | null; prep: string | null } | null {
const raw = line.trim();
if (!raw) return null;
// Split the line into parts
const parts = raw.split(/\s+/);
// If only one part, it's just a name
if (parts.length === 1) {
return { name: parts[0], quantity: null, unit: null, prep: null };
}
// Check if first part is a quantity
const firstPart = parts[0];
const quantity = parseMixedNumber(firstPart);
if (quantity !== null) {
// We have a quantity, check if second part is a unit
if (parts.length >= 3) {
const secondPart = parts[1];
// Check if second part looks like a unit (short, alphanumeric, and in our known units)
if (secondPart.length <= 4 && /^[a-zA-Z]+$/.test(secondPart)) {
const unitLower = secondPart.toLowerCase();
// Only treat as unit if it's in our known unit aliases
if (unitAliases[unitLower]) {
const unit = unitLower;
const name = parts.slice(2).join(' ');
// Check if there's prep info after a comma
const commaIndex = name.indexOf(',');
let finalName = name;
let prep = null;
if (commaIndex !== -1) {
finalName = name.substring(0, commaIndex);
prep = name.substring(commaIndex + 1).trim();
}
return {
name: finalName.trim(),
quantity,
unit,
prep
};
}
}
}
// We have a quantity but no clear unit, check if it's a no-space unit like "3g"
if (firstPart.length > 1) {
const match = firstPart.match(/^(\d+(?:[\s-]+\d+\/\d+|\/\d+|\.\d+)?)([a-zA-Z]+)$/);
if (match) {
const [, qtyStr, unit] = match;
const qtyNum = parseMixedNumber(qtyStr);
if (qtyNum !== null) {
const unitLower = unit.toLowerCase();
// Only treat as unit if it's in our known unit aliases
if (unitAliases[unitLower]) {
const name = parts.slice(1).join(' ');
const commaIndex = name.indexOf(',');
let finalName = name;
let prep = null;
if (commaIndex !== -1) {
finalName = name.substring(0, commaIndex);
prep = name.substring(commaIndex + 1).trim();
}
return {
name: finalName.trim(),
quantity: qtyNum,
unit: unitLower,
prep
};
}
}
}
}
// Quantity but no unit found
const name = parts.slice(1).join(' ');
const commaIndex = name.indexOf(',');
let finalName = name;
let prep = null;
if (commaIndex !== -1) {
finalName = name.substring(0, commaIndex);
prep = name.substring(commaIndex + 1).trim();
}
return {
name: finalName.trim(),
quantity,
unit: null,
prep
};
}
// No quantity found in first part, check if it's a no-space unit like "1g"
if (firstPart.length > 1) {
const match = firstPart.match(/^(\d+(?:[\s-]+\d+\/\d+|\/\d+|\.\d+)?)([a-zA-Z]+)$/);
if (match) {
const [, qtyStr, unit] = match;
const qtyNum = parseMixedNumber(qtyStr);
if (qtyNum !== null) {
const unitLower = unit.toLowerCase();
// Only treat as unit if it's in our known unit aliases
if (unitAliases[unitLower]) {
const name = parts.slice(1).join(' ');
const commaIndex = name.indexOf(',');
let finalName = name;
let prep = null;
if (commaIndex !== -1) {
finalName = name.substring(0, commaIndex);
prep = name.substring(commaIndex + 1).trim();
}
return {
name: finalName.trim(),
quantity: qtyNum,
unit: unitLower,
prep
};
}
}
}
}
// No quantity found, treat as name only
const name = parts.join(' ');
const commaIndex = name.indexOf(',');
let finalName = name;
let prep = null;
if (commaIndex !== -1) {
finalName = name.substring(0, commaIndex);
prep = name.substring(commaIndex + 1).trim();
}
return {
name: finalName.trim(),
quantity: null,
unit: null,
prep
};
}
function clearForm() {
// Clear all form inputs
const form = document.querySelector('form');
if (form) {
form.reset();
}
ingredientsText = '';
lastError = null;
lastSuccess = null;
formSubmitted = false;
}
async function onsubmit(event: SubmitEvent) { async function onsubmit(event: SubmitEvent) {
event.preventDefault();
const form = event.target as HTMLFormElement; const form = event.target as HTMLFormElement;
if (!form) return; if (!form) return;
pending = true; pending = true;
lastError = null; lastError = null;
lastSuccess = null;
try { try {
// native submit; progressive enhancement // Parse ingredients on client side
const ingredientsRaw = ingredientsText.trim();
const parsedIngredients: Array<{
name: string;
quantity: number | null;
unit: string | null;
prep: string | null;
}> = [];
if (ingredientsRaw) {
const lines = ingredientsRaw.split('\n').filter((line) => line.trim());
for (const line of lines) {
const parsed = parseIngredientLine(line.trim());
if (parsed) {
parsedIngredients.push(parsed);
}
}
}
// Create FormData with parsed ingredients
const formData = new FormData(form);
// Remove the raw ingredients textarea
formData.delete('ingredients');
// Add parsed ingredients as JSON
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
// Submit to server
const response = await fetch('?/create', {
method: 'POST',
body: formData
});
if (response.ok) {
// Show success message
lastSuccess = 'Recipe created successfully!';
formSubmitted = true;
// Clear the form after a short delay
clearForm();
// Redirect to the new recipe immediately after showing success message
const result = await response.json();
console.log(result);
if (result.location) {
window.location.href = result.location;
}
} else {
const errorData = await response.json();
lastError = errorData.message || 'Failed to create recipe';
}
} catch (error) {
lastError = 'An error occurred while creating the recipe';
console.error('Error creating recipe:', error);
} finally { } finally {
// allow server navigation to take over pending = false;
setTimeout(() => (pending = false), 400);
} }
} }
</script> </script>
<div class="mx-auto my-6 max-w-3xl px-5"> <div class="mx-auto my-4 w-full max-w-2xl px-3 sm:my-6 sm:px-6 lg:max-w-4xl">
<h1 class="mb-5 text-2xl font-extrabold">New Recipe</h1> <h1 class="mb-4 text-xl font-extrabold sm:mb-5 sm:text-2xl">New Recipe</h1>
<form <form
method="POST" method="POST"
action="?/create" action="?/create"
{onsubmit} {onsubmit}
class="card grid gap-4 border border-base-200 bg-base-100 p-4 shadow-xl" class="card relative grid gap-4 border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
> >
<div class="grid grid-cols-1 gap-3 md:grid-cols-3"> {#if pending}
<label class="form-control"> <div
class="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-base-100/80 backdrop-blur-sm"
>
<div class="text-center">
<span class="loading loading-lg loading-spinner text-primary"></span>
<p class="mt-2 text-base-content/70">Creating your recipe...</p>
</div>
</div>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<label class="form-control sm:col-span-2 lg:col-span-1">
<span class="label"><span class="label-text font-bold">Name</span></span> <span class="label"><span class="label-text font-bold">Name</span></span>
<input <input
name="name" name="name"
required required
autocomplete="off" autocomplete="off"
inputmode="text" inputmode="text"
class="input-bordered input input-lg" class="input-bordered input w-full"
/> />
</label> </label>
<label class="form-control"> <label class="form-control">
<span class="label"><span class="label-text font-bold">Time</span></span> <span class="label"><span class="label-text font-bold">Time</span></span>
<select name="time" class="select-bordered select select-lg"> <select name="time" class="select-bordered select w-full">
<option>Quick</option> <option>Quick</option>
<option selected>Medium</option> <option selected>Medium</option>
<option>Long</option> <option>Long</option>
@ -48,7 +342,7 @@
<label class="form-control"> <label class="form-control">
<span class="label"><span class="label-text font-bold">Station</span></span> <span class="label"><span class="label-text font-bold">Station</span></span>
<select name="station" class="select-bordered select select-lg"> <select name="station" class="select-bordered select w-full">
<option>Garde Manger</option> <option>Garde Manger</option>
<option selected>Pans</option> <option selected>Pans</option>
<option>Grill</option> <option>Grill</option>
@ -59,42 +353,103 @@
<label class="form-control"> <label class="form-control">
<span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span <span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span
> >
<textarea <textarea
name="ingredients" name="ingredients"
rows="6" rows="6"
placeholder="Eggs placeholder="2 cups flour, sifted
Butter 3g salt
Salt" 500ml water"
class="textarea-bordered textarea textarea-lg" class="textarea-bordered textarea w-full"
bind:value={ingredientsText}
></textarea> ></textarea>
</label> <div class="label">
<span class="label-text-alt text-sm text-base-content/60">
<strong>Pattern:</strong> [quantity] [unit] ingredient name[, prep notes]<br />
</span>
</div>
{#if ingredientsText}
<div class="mt-3 rounded-lg bg-base-200 p-3">
<div class="mb-2 text-sm font-semibold text-base-content/70">Live Preview:</div>
{#each ingredientsText.split('\n').filter((line) => line.trim()) as line, i}
{#if line.trim()}
{@const parsed = parseIngredientLine(line.trim())}
{#if parsed}
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2">
<div class="mb-1 text-xs text-base-content/50">Line {i + 1}:</div>
<div class="flex flex-wrap gap-2 text-sm">
{#if parsed.quantity}
<span class="badge badge-sm badge-primary">Qty: {parsed.quantity}</span>
{/if}
{#if parsed.unit}
<span class="badge badge-sm badge-secondary">Unit: {parsed.unit}</span>
{/if}
<span class="badge badge-sm badge-accent">Name: {parsed.name}</span>
{#if parsed.prep}
<span class="badge badge-outline badge-sm">Prep: {parsed.prep}</span>
{/if}
</div>
</div>
{/if}
{/if}
{/each}
</div>
{/if}
</label>
<div
class="tooltip tooltip-top"
data-tip="tsp, tbsp, cup(s), ml, l, g, kg, oz, lb, clove, piece, pc"
>
<strong class="cursor-help underline decoration-dotted">Valid units</strong>
</div>
<label class="form-control"> <label class="form-control">
<span class="label"><span class="label-text font-bold">Description</span></span> <span class="label"><span class="label-text font-bold">Description</span></span>
<textarea name="description" rows="3" class="textarea-bordered textarea textarea-lg" <textarea name="description" rows="3" class="textarea-bordered textarea w-full"></textarea>
></textarea>
</label> </label>
<label class="form-control"> <label class="form-control">
<span class="label"><span class="label-text font-bold">Instructions</span></span> <span class="label"><span class="label-text font-bold">Instructions</span></span>
<textarea name="instructions" rows="6" class="textarea-bordered textarea textarea-lg" <textarea name="instructions" rows="6" class="textarea-bordered textarea w-full"></textarea>
></textarea>
</label> </label>
<div class="mt-1 flex justify-end gap-3"> <div class="mt-2 flex flex-col gap-3 sm:flex-row sm:justify-end">
<a class="btn" href="/">Cancel</a> <a class="btn order-2 w-full sm:order-1 sm:w-auto" href="/">Cancel</a>
<button <button
class="btn font-extrabold btn-lg btn-primary" class="btn order-1 w-full font-extrabold btn-primary sm:order-2 sm:w-auto"
type="submit" type="submit"
name="/create" name="/create"
disabled={pending} disabled={pending}
> >
{pending ? 'Saving…' : 'Save'} {#if pending}
<span class="loading loading-sm loading-spinner"></span>
Creating Recipe…
{:else}
Create Recipe
{/if}
</button> </button>
</div> </div>
{#if lastError} {#if lastError}
<p class="text-error">{lastError}</p> <p class="text-error">{lastError}</p>
{/if} {/if}
{#if lastSuccess}
<div class="mt-3 alert alert-success">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{lastSuccess}</span>
</div>
{/if}
</form> </form>
</div> </div>

View File

@ -0,0 +1,87 @@
import type { RequestHandler } from './$types';
import prisma from '$lib/server/prisma';
export const POST: RequestHandler = async ({ request }) => {
const formData = await request.formData();
const name = (formData.get('name') as string | null)?.trim() ?? '';
const description = (formData.get('description') as string | null)?.trim() || null;
const instructions = (formData.get('instructions') as string | null)?.trim() || null;
const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string;
const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string;
const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: string | null }> = [];
if (!name) {
return new Response(JSON.stringify({ type: 'error', message: 'Name is required' }), { status: 400 });
}
if (parsedIngredientsRaw) {
try {
parsedIngredients = JSON.parse(parsedIngredientsRaw);
} catch (error) {
console.error('Failed to parse ingredients JSON:', error);
return new Response(JSON.stringify({ type: 'error', message: 'Invalid ingredients format' }), { status: 400 });
}
}
// Create recipe
const recipe = await prisma.recipe.create({
data: {
name,
description,
instructions,
time,
station
}
});
if (parsedIngredients.length > 0) {
// Upsert ingredients first
const ingredientNames = parsedIngredients.map(i => i.name).filter(Boolean);
const existingIngredients = await prisma.ingredient.findMany({
where: { name: { in: ingredientNames } }
});
const existingNames = new Set(existingIngredients.map(i => i.name));
const createIngredients = ingredientNames
.filter(n => !existingNames.has(n))
.map(n => ({ name: n }));
if (createIngredients.length) {
await prisma.ingredient.createMany({
data: createIngredients
});
}
// Get all ingredients with ids
const allIngredients = await prisma.ingredient.findMany({
where: { name: { in: ingredientNames } }
});
const ingredientMap = new Map(allIngredients.map(i => [i.name, i.id]));
// Bulk create recipeIngredient
const recipeIngredientsData = parsedIngredients
.filter(i => i.name)
.map(i => ({
recipeId: recipe.id,
ingredientId: ingredientMap.get(i.name)!,
quantity: i.quantity,
unit: i.unit,
prep: i.prep
}));
if (recipeIngredientsData.length) {
await prisma.recipeIngredient.createMany({
data: recipeIngredientsData
});
}
}
return new Response(JSON.stringify({
type: 'success',
status: 200,
location: `/recipe/${recipe.id}`
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};