commit 97733b8570c4268acc550e3e8000cac90bd2b6fd Author: Chris Date: Tue May 26 19:57:35 2026 -0400 Initial commit: GourmetVault recipe manager diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bd347f --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependency directories +node_modules/ +jspm_packages/ +web_modules/ + +# Local database and volume directories +data/ +*.db +*.sqlite + +# Environment secrets +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor and OS files +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Scratch and test scripts +test_scraper.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7e8977 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Base image: Minimal LTS Node Alpine +FROM node:20-alpine + +# Setup workspace environment +WORKDIR /usr/src/app + +# Copy dependency manifests +COPY package*.json ./ + +# Install clean production dependencies +RUN npm ci --only=production + +# Copy application source files +COPY . . + +# Setup persistent volume mount point +RUN mkdir -p /usr/src/app/data + +# Default port exposure +EXPOSE 3000 + +# Start command +CMD [ "npm", "start" ] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..31c655e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + recipe-manager: + build: . + container_name: recipe-manager-app + ports: + - "3000:3000" + volumes: + - ./data:/usr/src/app/data + restart: unless-stopped + environment: + - PORT=3000 + - NODE_ENV=production diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0cceaf3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1344 @@ +{ + "name": "recipe-manager", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recipe-manager", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.2", + "cheerio": "^1.0.0-rc.12", + "cors": "^2.8.5", + "express": "^4.19.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..43207fc --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "recipe-manager", + "version": "1.0.0", + "description": "Dockerized Recipe Management and Web Scraping Application", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "axios": "^1.7.2", + "cheerio": "^1.0.0-rc.12", + "cors": "^2.8.5", + "express": "^4.19.2" + }, + "private": true +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..b853a85 --- /dev/null +++ b/public/app.js @@ -0,0 +1,813 @@ +// GourmetVault client application state +let allRecipes = []; +let activeRecipe = null; +let currentMultiplier = 1; +let selectedCoverImage = ''; +let stopwatchInterval = null; +let stopwatchSeconds = 0; +let cookingActiveStep = 0; + +// Page elements caching +const elements = { + themeToggle: document.getElementById('theme-toggle'), + urlScrapeForm: document.getElementById('url-scrape-form'), + scrapeUrlInput: document.getElementById('scrape-url-input'), + btnScrapeSubmit: document.getElementById('btn-scrape-submit'), + btnManualAdd: document.getElementById('btn-manual-add'), + recipesGrid: document.getElementById('recipes-grid'), + emptyState: document.getElementById('empty-state'), + librarySearch: document.getElementById('library-search'), + + // Scrape / Edit Modal + saveModal: document.getElementById('save-modal'), + recipeEditForm: document.getElementById('recipe-edit-form'), + editRecipeId: document.getElementById('edit-recipe-id'), + editTitle: document.getElementById('edit-title'), + editSource: document.getElementById('edit-source'), + editDescription: document.getElementById('edit-description'), + editServings: document.getElementById('edit-servings'), + editPrepTime: document.getElementById('edit-prep-time'), + editCookTime: document.getElementById('edit-cook-time'), + imageSelectionGrid: document.getElementById('image-selection-grid'), + editImageUrl: document.getElementById('edit-image-url'), + editIngredients: document.getElementById('edit-ingredients'), + editInstructions: document.getElementById('edit-instructions'), + modalActionTitle: document.getElementById('modal-action-title'), + btnSaveConfirm: document.getElementById('btn-save-confirm'), + + // Detail Modal + detailModal: document.getElementById('detail-modal'), + recipeDetailBanner: document.getElementById('recipe-detail-banner'), + btnDetailEdit: document.getElementById('btn-detail-edit'), + btnDetailDelete: document.getElementById('btn-detail-delete'), + recipeDetailTitle: document.getElementById('recipe-detail-title'), + tagServings: document.getElementById('tag-servings'), + tagPrep: document.getElementById('tag-prep'), + tagCook: document.getElementById('tag-cook'), + recipeDetailDescription: document.getElementById('recipe-detail-description'), + recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'), + recipeDetailInstructions: document.getElementById('recipe-detail-instructions'), + scalerMinus: document.getElementById('scaler-minus'), + scalerPlus: document.getElementById('scaler-plus'), + currentServingsVal: document.getElementById('current-servings-val'), + btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'), + + // Cooking Mode Modal + cookingModeModal: document.getElementById('cooking-mode-modal'), + cookRecipeTitle: document.getElementById('cook-recipe-title'), + cookTimerVal: document.getElementById('cook-timer-val'), + cookTimerPlay: document.getElementById('cook-timer-play'), + cookTimerReset: document.getElementById('cook-timer-reset'), + cookProgressBar: document.getElementById('cook-progress-bar'), + cookStepIndex: document.getElementById('cook-step-index'), + cookStepInstruction: document.getElementById('cook-step-instruction'), + cookBtnPrev: document.getElementById('cook-btn-prev'), + cookBtnNext: document.getElementById('cook-btn-next'), + cookStepDots: document.getElementById('cook-step-dots'), + + // Loader + loadingOverlay: document.getElementById('loading-overlay'), + loaderStepFetch: document.getElementById('loader-step-fetch'), + loaderStepParse: document.getElementById('loader-step-parse'), + loaderStepImages: document.getElementById('loader-step-images') +}; + +// ------------------------------------------------------------- +// LIFE CYCLE AND REGISTRATION +// ------------------------------------------------------------- + +window.addEventListener('DOMContentLoaded', () => { + initTheme(); + fetchRecipes(); + registerEvents(); +}); + +function registerEvents() { + // Theme Toggle + elements.themeToggle.addEventListener('click', toggleTheme); + + // URL Form Submission + elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit); + + // Manual Add Form Opening + elements.btnManualAdd.addEventListener('click', openManualAdd); + + // Library Search + elements.librarySearch.addEventListener('input', filterRecipes); + + // Recipe Form Save + elements.recipeEditForm.addEventListener('submit', handleRecipeSave); + + // Action Buttons Detail Modal + elements.btnDetailEdit.addEventListener('click', editActiveRecipe); + elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe); + + // Servings Scaler Buttons + elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5)); + elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5)); + + // Cooking Mode Trigger + elements.btnTriggerCookmode.addEventListener('click', enterCookingMode); + + // Cooking Controls + elements.cookBtnPrev.addEventListener('click', prevCookingStep); + elements.cookBtnNext.addEventListener('click', nextCookingStep); + elements.cookTimerPlay.addEventListener('click', toggleTimer); + elements.cookTimerReset.addEventListener('click', resetTimer); + + // Global Modals click to dismiss on overlay + document.querySelectorAll('.modal-overlay').forEach(overlay => { + overlay.addEventListener('click', (e) => { + if (e.target === overlay && !overlay.classList.contains('loading-overlay')) { + closeModal(overlay.id); + } + }); + }); + + // Global key bindings for cooking mode + document.addEventListener('keydown', (e) => { + if (elements.cookingModeModal.classList.contains('active')) { + if (e.key === 'ArrowRight' || e.key === ' ') { + e.preventDefault(); + nextCookingStep(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + prevCookingStep(); + } else if (e.key === 'Escape') { + exitCookingMode(); + } + } + }); +} + +// ------------------------------------------------------------- +// UTILITIES: SYSTEM THEMES +// ------------------------------------------------------------- + +function initTheme() { + const savedTheme = localStorage.getItem('gourmet-theme') || 'dark'; + if (savedTheme === 'dark') { + document.body.classList.add('dark-theme'); + } else { + document.body.classList.remove('dark-theme'); + } +} + +function toggleTheme() { + document.body.classList.toggle('dark-theme'); + const isDark = document.body.classList.contains('dark-theme'); + localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light'); +} + +// ------------------------------------------------------------- +// LOADING STATUS MACHINE +// ------------------------------------------------------------- + +function setLoaderStepIcon(stepElement, iconName, spin = false) { + const wrapper = stepElement.querySelector('.step-icon-wrapper'); + if (wrapper) { + wrapper.innerHTML = ``; + } +} + +function showScrapeLoading() { + elements.loadingOverlay.classList.add('active'); + + // Reset Steps to default + elements.loaderStepFetch.className = 'loader-step active'; + elements.loaderStepParse.className = 'loader-step'; + elements.loaderStepImages.className = 'loader-step'; + + setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true); + setLoaderStepIcon(elements.loaderStepParse, 'circle', false); + setLoaderStepIcon(elements.loaderStepImages, 'circle', false); + + lucide.createIcons(); + + // Simulate process transitions + setTimeout(() => { + if (!elements.loadingOverlay.classList.contains('active')) return; + elements.loaderStepFetch.className = 'loader-step completed'; + setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false); + + elements.loaderStepParse.className = 'loader-step active'; + setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true); + lucide.createIcons(); + }, 1800); + + setTimeout(() => { + if (!elements.loadingOverlay.classList.contains('active')) return; + elements.loaderStepParse.className = 'loader-step completed'; + setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false); + + elements.loaderStepImages.className = 'loader-step active'; + setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true); + lucide.createIcons(); + }, 3500); +} + +function hideScrapeLoading() { + elements.loadingOverlay.classList.remove('active'); +} + +// ------------------------------------------------------------- +// CORE MODAL ROUTER +// ------------------------------------------------------------- + +function openModal(id) { + document.getElementById(id).classList.add('active'); +} + +function closeModal(id) { + document.getElementById(id).classList.remove('active'); + if (id === 'cooking-mode-modal') { + exitCookingMode(); + } +} + +// ------------------------------------------------------------- +// CRUD & API HANDLERS +// ------------------------------------------------------------- + +async function fetchRecipes() { + try { + const response = await fetch('/api/recipes'); + allRecipes = await response.json(); + renderLibrary(); + } catch (error) { + console.error('Error fetching recipes library:', error); + } +} + +// Scrape link form submission +async function handleScrapeSubmit(e) { + e.preventDefault(); + const url = elements.scrapeUrlInput.value.trim(); + if (!url) return; + + showScrapeLoading(); + + try { + const response = await fetch('/api/scrape', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + if (response.status === 403 || errorData.isBlocked) { + throw new Error('BLOCKED'); + } + throw new Error(errorData.error || 'Extraction error.'); + } + + const data = await response.json(); + hideScrapeLoading(); + openDraftPreview(data); + elements.scrapeUrlInput.value = ''; + } catch (error) { + hideScrapeLoading(); + if (error.message === 'BLOCKED') { + alert(`Access Blocked by Site Protection\n\nThis website uses Cloudflare or bot-defense screens to prevent automated scraping.\n\nDon't worry! You can still add this recipe by clicking the "Manual Recipe" button at the top to quickly paste your ingredients and directions.`); + } else { + alert(`Scraping failed: ${error.message}`); + } + } +} + +// Save or Edit submit +async function handleRecipeSave(e) { + e.preventDefault(); + + const id = elements.editRecipeId.value; + const recipePayload = { + title: elements.editTitle.value.trim(), + sourceUrl: elements.editSource.value.trim(), + description: elements.editDescription.value.trim(), + servings: elements.editServings.value.trim(), + prepTime: elements.editPrepTime.value.trim(), + cookTime: elements.editCookTime.value.trim(), + image: elements.editImageUrl.value.trim(), + images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src), + ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean), + instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean) + }; + + try { + let response; + if (id) { + // Edit + response = await fetch(`/api/recipes/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(recipePayload) + }); + } else { + // Create + response = await fetch('/api/recipes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(recipePayload) + }); + } + + if (!response.ok) throw new Error('Saving failed.'); + + const saved = await response.json(); + closeModal('save-modal'); + fetchRecipes(); + + // If active view was open, update it + if (activeRecipe && activeRecipe.id === saved.id) { + showRecipeDetails(saved); + } + } catch (error) { + console.error('Error saving recipe:', error); + alert('Error saving recipe.'); + } +} + +// Delete Active Recipe +async function deleteActiveRecipe() { + if (!activeRecipe) return; + if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return; + + try { + const response = await fetch(`/api/recipes/${activeRecipe.id}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Deletion failed.'); + + closeModal('detail-modal'); + activeRecipe = null; + fetchRecipes(); + } catch (error) { + console.error('Error deleting recipe:', error); + alert('Deletion failed.'); + } +} + +// ------------------------------------------------------------- +// DRAFT / MANUAL CREATOR MODAL POPULATOR +// ------------------------------------------------------------- + +function openManualAdd() { + elements.modalActionTitle.textContent = 'Create New Recipe'; + elements.editRecipeId.value = ''; + elements.recipeEditForm.reset(); + elements.imageSelectionGrid.innerHTML = ''; + selectedCoverImage = ''; + + openModal('save-modal'); +} + +function openDraftPreview(data) { + elements.modalActionTitle.textContent = 'Save Extracted Recipe'; + elements.editRecipeId.value = ''; + + elements.editTitle.value = data.title || ''; + elements.editSource.value = data.sourceUrl || ''; + elements.editDescription.value = data.description || ''; + elements.editServings.value = data.servings || ''; + elements.editPrepTime.value = data.prepTime || ''; + elements.editCookTime.value = data.cookTime || ''; + + // Ingredients text area + elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : ''; + + // Instructions text area + elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : ''; + + // Render Image Selector + renderImageSelector(data.images || []); + + openModal('save-modal'); +} + +function editActiveRecipe() { + if (!activeRecipe) return; + + elements.modalActionTitle.textContent = 'Edit Recipe Details'; + elements.editRecipeId.value = activeRecipe.id; + + elements.editTitle.value = activeRecipe.title || ''; + elements.editSource.value = activeRecipe.sourceUrl || ''; + elements.editDescription.value = activeRecipe.description || ''; + elements.editServings.value = activeRecipe.servings || ''; + elements.editPrepTime.value = activeRecipe.prepTime || ''; + elements.editCookTime.value = activeRecipe.cookTime || ''; + + elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : ''; + elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : ''; + + renderImageSelector(activeRecipe.images || []); + + // Set active selection + if (activeRecipe.image) { + elements.editImageUrl.value = activeRecipe.image; + selectedCoverImage = activeRecipe.image; + selectImageThumbnail(activeRecipe.image); + } + + openModal('save-modal'); +} + +// ------------------------------------------------------------- +// IMAGE EXTRACTION CAROUSEL & SELECTOR +// ------------------------------------------------------------- + +function renderImageSelector(images) { + elements.imageSelectionGrid.innerHTML = ''; + selectedCoverImage = ''; + + if (!images || images.length === 0) { + elements.editImageUrl.value = ''; + return; + } + + // Pre-select first image + selectedCoverImage = images[0]; + elements.editImageUrl.value = images[0]; + + images.forEach((imgSrc, index) => { + const option = document.createElement('div'); + option.className = `image-option ${index === 0 ? 'selected' : ''}`; + option.innerHTML = `extracted thumbnail`; + + option.addEventListener('click', () => { + elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => el.classList.remove('selected')); + option.classList.add('selected'); + selectedCoverImage = imgSrc; + elements.editImageUrl.value = imgSrc; + }); + + elements.imageSelectionGrid.appendChild(option); + }); +} + +function selectImageThumbnail(url) { + elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => { + const img = el.querySelector('img'); + if (img && img.src === url) { + el.classList.add('selected'); + } else { + el.classList.remove('selected'); + } + }); +} + +// ------------------------------------------------------------- +// DASHBOARD RENDERING +// ------------------------------------------------------------- + +function renderLibrary() { + elements.recipesGrid.innerHTML = ''; + + const searchVal = elements.librarySearch.value.toLowerCase().trim(); + const filtered = allRecipes.filter(recipe => { + return recipe.title.toLowerCase().includes(searchVal) || + (recipe.description && recipe.description.toLowerCase().includes(searchVal)) || + (recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(searchVal))); + }); + + if (filtered.length === 0) { + elements.emptyState.style.display = 'flex'; + return; + } + + elements.emptyState.style.display = 'none'; + + filtered.forEach(recipe => { + const domain = recipe.sourceUrl ? new URL(recipe.sourceUrl).hostname.replace('www.', '') : 'Manual Recipe'; + + const card = document.createElement('div'); + card.className = 'recipe-card'; + card.addEventListener('click', () => showRecipeDetails(recipe)); + + let imgBlock = ''; + if (recipe.image) { + imgBlock = `${recipe.title}`; + } else { + imgBlock = ` +
+ + No dish photo +
+ `; + } + + card.innerHTML = ` +
+ ${imgBlock} +
+
+ ${domain} +

${recipe.title}

+

${recipe.description || 'No description available for this recipe.'}

+ +
+ ${recipe.servings ? ` ${recipe.servings} Servings` : ''} + ${recipe.totalTime ? ` ${recipe.totalTime}` : ''} +
+
+ `; + + elements.recipesGrid.appendChild(card); + }); + + lucide.createIcons(); +} + +function filterRecipes() { + renderLibrary(); +} + +// ------------------------------------------------------------- +// DYNAMIC SERVINGS QUANTITY SCALER +// ------------------------------------------------------------- + +function scaleIngredientText(text, multiplier) { + if (multiplier === 1) return text; + + // Mixed number regex: e.g. "1 1/2" or "2 1/4" + const mixedRegex = /^(\d+)\s+(\d+\/\d+)\s*(.*)/; + const mixedMatch = text.match(mixedRegex); + if (mixedMatch) { + const whole = parseInt(mixedMatch[1], 10); + const fracParts = mixedMatch[2].split('/'); + const fracVal = parseInt(fracParts[0], 10) / parseInt(fracParts[1], 10); + const total = (whole + fracVal) * multiplier; + return `${formatScaledNumber(total)} ${mixedMatch[3]}`; + } + + // Pure Fraction regex: e.g. "1/2" or "3/4" + const fracRegex = /^(\d+\/\d+)\s*(.*)/; + const fracMatch = text.match(fracRegex); + if (fracMatch) { + const parts = fracMatch[1].split('/'); + const val = (parseInt(parts[0], 10) / parseInt(parts[1], 10)) * multiplier; + return `${formatScaledNumber(val)} ${fracMatch[2]}`; + } + + // Decimals & Integers regex: e.g. "1.5" or "4" + const numRegex = /^(\d+(?:\.\d+)?)\s*(.*)/; + const numMatch = text.match(numRegex); + if (numMatch) { + const val = parseFloat(numMatch[1]) * multiplier; + return `${formatScaledNumber(val)} ${numMatch[2]}`; + } + + return text; +} + +function formatScaledNumber(num) { + if (num % 1 === 0) return num.toString(); + + const rounded = Math.round(num * 100) / 100; + const decimal = rounded % 1; + const integer = Math.floor(rounded); + + // Common fraction mappings for nice reading + if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4'; + if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2'; + if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4'; + if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3'; + if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3'; + + return rounded.toString(); +} + +function adjustMultiplier(change) { + const newMulti = currentMultiplier + change; + if (newMulti < 0.5 || newMulti > 10) return; + + currentMultiplier = newMulti; + elements.currentServingsVal.textContent = `${currentMultiplier}x`; + renderScaledIngredients(); +} + +function renderScaledIngredients() { + elements.recipeDetailIngredients.innerHTML = ''; + + if (!activeRecipe || !activeRecipe.ingredients) return; + + activeRecipe.ingredients.forEach((ing, index) => { + const scaledText = scaleIngredientText(ing, currentMultiplier); + + const li = document.createElement('li'); + li.className = 'ingredient-checkbox-item'; + li.innerHTML = ` +
+ ${scaledText} + `; + + li.addEventListener('click', () => { + li.classList.toggle('checked'); + }); + + elements.recipeDetailIngredients.appendChild(li); + }); + + lucide.createIcons(); +} + +// ------------------------------------------------------------- +// RECIPE DETAIL VIEWER COMPONENT +// ------------------------------------------------------------- + +function showRecipeDetails(recipe) { + activeRecipe = recipe; + currentMultiplier = 1; + elements.currentServingsVal.textContent = '1x'; + + // Set hero cover banner image + if (recipe.image) { + elements.recipeDetailBanner.style.backgroundImage = `url('${recipe.image}')`; + } else { + elements.recipeDetailBanner.style.backgroundImage = 'none'; + elements.recipeDetailBanner.style.backgroundColor = 'var(--bg-app)'; + } + + elements.recipeDetailTitle.textContent = recipe.title; + elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.'; + + // Setup meta tags + if (recipe.servings) { + elements.tagServings.style.display = 'flex'; + elements.tagServings.querySelector('.val').textContent = `${recipe.servings} Servings`; + } else { + elements.tagServings.style.display = 'none'; + } + + if (recipe.prepTime) { + elements.tagPrep.style.display = 'flex'; + elements.tagPrep.querySelector('.val').textContent = recipe.prepTime; + } else { + elements.tagPrep.style.display = 'none'; + } + + if (recipe.cookTime) { + elements.tagCook.style.display = 'flex'; + elements.tagCook.querySelector('.val').textContent = recipe.cookTime; + } else { + elements.tagCook.style.display = 'none'; + } + + // Populate Ingredients with scaler + renderScaledIngredients(); + + // Populate Directions Steps + elements.recipeDetailInstructions.innerHTML = ''; + if (recipe.instructions) { + recipe.instructions.forEach((step, index) => { + const li = document.createElement('li'); + + // Look for section headers (e.g. ### Sauce) + if (step.startsWith('###')) { + li.className = 'instruction-step-item section-header-step'; + li.innerHTML = ` +
${step.replace('###', '').trim()}
+ `; + } else { + li.className = 'instruction-step-item'; + li.innerHTML = ` +
${index + 1}
+
${step}
+ `; + + li.addEventListener('click', () => { + li.classList.toggle('checked'); + }); + } + + elements.recipeDetailInstructions.appendChild(li); + }); + } + + openModal('detail-modal'); +} + +// ------------------------------------------------------------- +// INTERACTIVE FULLSCREEN COOKING ASSISTANT +// ------------------------------------------------------------- + +function enterCookingMode() { + if (!activeRecipe || !activeRecipe.instructions || activeRecipe.instructions.length === 0) return; + + elements.cookRecipeTitle.textContent = activeRecipe.title; + cookingActiveStep = 0; + + // Filter out header steps from the cooking carousel to keep steps clean + const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); + + if (cleanInstructions.length === 0) return; + + elements.cookingModeModal.classList.add('active'); + document.body.style.overflow = 'hidden'; // Lock background scrolling + + renderCookingStep(cleanInstructions); + + // Reset Timer + resetTimer(); + startTimer(); +} + +function exitCookingMode() { + elements.cookingModeModal.classList.remove('active'); + document.body.style.overflow = 'auto'; // Unlock background scrolling + stopTimer(); +} + +function renderCookingStep(instructions) { + const stepText = instructions[cookingActiveStep]; + elements.cookStepIndex.textContent = `Step ${cookingActiveStep + 1} of ${instructions.length}`; + elements.cookStepInstruction.textContent = stepText; + + // Update progress bar + const progressPercent = ((cookingActiveStep + 1) / instructions.length) * 100; + elements.cookProgressBar.style.width = `${progressPercent}%`; + + // Render step navigation dot indicators + elements.cookStepDots.innerHTML = ''; + instructions.forEach((_, idx) => { + const dot = document.createElement('div'); + dot.className = `dot ${idx === cookingActiveStep ? 'active' : ''}`; + dot.addEventListener('click', () => { + cookingActiveStep = idx; + renderCookingStep(instructions); + }); + elements.cookStepDots.appendChild(dot); + }); + + // Enable/disable buttons + elements.cookBtnPrev.disabled = cookingActiveStep === 0; + const nextBtnIconWrapper = elements.cookBtnNext.querySelector('.btn-icon-wrapper'); + if (cookingActiveStep === instructions.length - 1) { + elements.cookBtnNext.querySelector('span').textContent = 'Finish Cooking'; + if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = ''; + } else { + elements.cookBtnNext.querySelector('span').textContent = 'Next Step'; + if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = ''; + } + lucide.createIcons(); +} + +function prevCookingStep() { + const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); + if (cookingActiveStep > 0) { + cookingActiveStep--; + renderCookingStep(cleanInstructions); + } +} + +function nextCookingStep() { + const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); + if (cookingActiveStep < cleanInstructions.length - 1) { + cookingActiveStep++; + renderCookingStep(cleanInstructions); + } else { + // Finished cooking + exitCookingMode(); + alert('Congratulations! Enjoy your delicious meal.'); + } +} + +// Timer/Stopwatch Functions +function startTimer() { + if (stopwatchInterval) return; + + elements.cookTimerPlay.innerHTML = ''; + lucide.createIcons(); + + stopwatchInterval = setInterval(() => { + stopwatchSeconds++; + const mins = Math.floor(stopwatchSeconds / 60).toString().padStart(2, '0'); + const secs = (stopwatchSeconds % 60).toString().padStart(2, '0'); + elements.cookTimerVal.textContent = `${mins}:${secs}`; + }, 1000); +} + +function stopTimer() { + if (stopwatchInterval) { + clearInterval(stopwatchInterval); + stopwatchInterval = null; + } + elements.cookTimerPlay.innerHTML = ''; + lucide.createIcons(); +} + +function toggleTimer() { + if (stopwatchInterval) { + stopTimer(); + } else { + startTimer(); + } +} + +function resetTimer() { + stopTimer(); + stopwatchSeconds = 0; + elements.cookTimerVal.textContent = '00:00'; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..11c107e --- /dev/null +++ b/public/index.html @@ -0,0 +1,302 @@ + + + + + + GourmetVault – Curated Recipe Manager + + + + + + + + + + + +
+ + +
+ + +
+
+ + +
+ +
+
+

Save Recipes from Anywhere Instantly

+

Paste any cooking link below. Our engine will extract ingredients, directions, times, and images directly into your personal digital cookbook.

+
+ +
+
+ + +
+ +
+
+ + +
+
+

My Cookbook

+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
+
+

Your vault is empty

+

Insert a cooking web link above or create one manually to build your digital recipe collections.

+
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..e50ac2a --- /dev/null +++ b/public/style.css @@ -0,0 +1,1447 @@ +/* GourmetVault CSS - Premium Styling System */ + +/* Design Tokens & Root Custom Variables */ +:root { + /* Fonts */ + --font-heading: 'Outfit', 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-body: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Global Transitions */ + --transition-fast: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --transition-normal: 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 0.5s cubic-bezier(0.4, 0, 0.2, 1); + --border-radius-sm: 8px; + --border-radius-md: 14px; + --border-radius-lg: 24px; + --border-radius-full: 9999px; + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1); + --shadow-md: 0 8px 30px rgba(0, 0, 0, 0.12); + --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.2); + + /* HSL Colors (Orange/Amber Culinary Theme) */ + --hue-primary: 28; + --hue-secondary: 14; + --hue-neutral: 215; + + /* Theme Dependent Variables (Defined for Light Theme first, toggled to Dark below) */ + --primary: hsl(var(--hue-primary), 95%, 52%); + --primary-hover: hsl(var(--hue-primary), 95%, 44%); + --primary-glow: hsla(var(--hue-primary), 95%, 52%, 0.15); + + --secondary: hsl(var(--hue-secondary), 88%, 56%); + + --accent: hsl(142, 70%, 45%); /* Cook mode green */ + --danger: hsl(0, 84%, 60%); + --danger-hover: hsl(0, 84%, 50%); + + --bg-app: hsl(var(--hue-neutral), 30%, 96%); + --bg-card: hsl(0, 0%, 100%); + --bg-nav: rgba(255, 255, 255, 0.8); + --border-color: hsl(var(--hue-neutral), 20%, 88%); + + --text-main: hsl(var(--hue-neutral), 40%, 16%); + --text-muted: hsl(var(--hue-neutral), 20%, 45%); + --text-white: #ffffff; + + --glass-bg: rgba(255, 255, 255, 0.6); + --glass-border: rgba(255, 255, 255, 0.4); + --glass-blur: blur(12px); +} + +/* Dark Theme Overrides */ +.dark-theme, +body.dark-theme { + --bg-app: hsl(var(--hue-neutral), 38%, 7%); + --bg-card: hsl(var(--hue-neutral), 30%, 11%); + --bg-nav: rgba(15, 23, 42, 0.8); + --border-color: hsl(var(--hue-neutral), 25%, 18%); + + --text-main: hsl(var(--hue-neutral), 15%, 92%); + --text-muted: hsl(var(--hue-neutral), 15%, 65%); + + --glass-bg: rgba(15, 23, 42, 0.55); + --glass-border: rgba(255, 255, 255, 0.06); + --shadow-md: 0 8px 32px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 20px 50px rgba(0, 0, 0, 0.5); +} + +/* Base Resets */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: var(--font-body); +} + +body { + background-color: var(--bg-app); + color: var(--text-main); + transition: background-color var(--transition-normal), color var(--transition-normal); + min-height: 100vh; + overflow-x: hidden; +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-heading); + font-weight: 700; + letter-spacing: -0.02em; +} + +/* Utility Components */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + border-radius: var(--border-radius-md); + font-family: var(--font-heading); + font-weight: 600; + font-size: 0.95rem; + border: none; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.btn-primary { + background-color: var(--primary); + color: var(--text-white); + box-shadow: 0 4px 14px var(--primary-glow); +} + +.btn-primary:hover { + background-color: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 6px 20px var(--primary-glow); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-outline { + background-color: transparent; + border: 2px solid var(--border-color); + color: var(--text-main); +} + +.btn-outline:hover { + background-color: var(--border-color); + border-color: var(--text-muted); +} + +.btn-accent { + background-color: var(--accent); + color: var(--text-white); + box-shadow: 0 4px 14px rgba(16, 185, 129, 0.25); +} + +.btn-accent:hover { + background-color: hsl(142, 70%, 38%); + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(16, 185, 129, 0.35); +} + +.btn-icon-round { + width: 44px; + height: 44px; + border-radius: var(--border-radius-full); + display: flex; + align-items: center; + justify-content: center; + border: none; + background-color: var(--glass-bg); + border: 1px solid var(--glass-border); + color: var(--text-main); + cursor: pointer; + transition: all var(--transition-fast); + backdrop-filter: var(--glass-blur); +} + +.btn-icon-round:hover { + transform: scale(1.08); + background-color: var(--border-color); +} + +.btn-danger-hover:hover { + background-color: var(--danger); + color: var(--text-white); + border-color: var(--danger); +} + +.btn-lg { + padding: 14px 28px; + font-size: 1.05rem; + border-radius: var(--border-radius-md); +} + +.close-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-full); + transition: all var(--transition-fast); +} + +.close-btn:hover { + background-color: var(--border-color); + color: var(--text-main); +} + +/* Header Styling */ +.app-header { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 5%; + background-color: var(--bg-nav); + border-bottom: 1px solid var(--border-color); + backdrop-filter: var(--glass-blur); + transition: background-color var(--transition-normal), border-color var(--transition-normal); +} + +.header-logo { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.logo-icon { + background: linear-gradient(135deg, var(--primary), var(--secondary)); + width: 40px; + height: 40px; + border-radius: var(--border-radius-md); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-white); + box-shadow: 0 4px 12px var(--primary-glow); +} + +.logo-text { + font-family: var(--font-heading); + font-size: 1.5rem; + font-weight: 800; + color: var(--text-main); +} + +.logo-text span { + color: var(--primary); +} + +.header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.theme-toggle { + background: none; + border: 1px solid var(--border-color); + width: 40px; + height: 40px; + border-radius: var(--border-radius-md); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition-fast); +} + +.theme-toggle:hover { + background-color: var(--border-color); + color: var(--text-main); +} + +/* Theme Toggle Icon Behaviors */ +.theme-toggle .sun-icon { display: none; } +.theme-toggle .moon-icon { display: block; } +.dark-theme .theme-toggle .sun-icon { display: block; } +.dark-theme .theme-toggle .moon-icon { display: none; } + +/* Main Container */ +.app-container { + max-width: 1400px; + margin: 0 auto; + padding: 40px 5%; +} + +/* Hero & Input Bar Section */ +.scraping-hero { + text-align: center; + margin-bottom: 50px; + animation: fadeIn 0.8s ease-out; +} + +.hero-content h1 { + font-size: 2.8rem; + line-height: 1.15; + margin-bottom: 12px; + background: linear-gradient(to right, var(--text-main), hsl(var(--hue-primary), 90%, 55%)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.hero-content p { + font-size: 1.15rem; + color: var(--text-muted); + max-width: 650px; + margin: 0 auto 30px auto; + line-height: 1.6; +} + +.url-input-form { + max-width: 750px; + margin: 0 auto; + display: flex; + gap: 12px; + background-color: var(--bg-card); + padding: 8px; + border-radius: var(--border-radius-lg); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-md); + transition: all var(--transition-normal); +} + +.url-input-form:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 4px var(--primary-glow); +} + +.input-wrapper { + position: relative; + flex-grow: 1; + display: flex; + align-items: center; +} + +.input-icon { + position: absolute; + left: 16px; + color: var(--text-muted); + width: 20px; + height: 20px; +} + +.url-input-form input { + width: 100%; + padding: 14px 14px 14px 50px; + background: transparent; + border: none; + font-size: 1rem; + color: var(--text-main); + outline: none; +} + +.url-input-form input::placeholder { + color: var(--text-muted); + opacity: 0.8; +} + +/* Recipes Dashboard Library */ +.library-section { + border-top: 1px solid var(--border-color); + padding-top: 40px; +} + +.library-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; +} + +.section-title { + font-size: 1.8rem; + color: var(--text-main); +} + +.filter-controls { + display: flex; + gap: 12px; + align-items: center; +} + +.search-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.search-icon { + position: absolute; + left: 14px; + color: var(--text-muted); + width: 18px; + height: 18px; +} + +.search-wrapper input { + padding: 10px 14px 10px 42px; + border-radius: var(--border-radius-md); + border: 1px solid var(--border-color); + background-color: var(--bg-card); + color: var(--text-main); + outline: none; + font-size: 0.95rem; + width: 260px; + transition: all var(--transition-fast); +} + +.search-wrapper input:focus { + border-color: var(--primary); + width: 320px; + box-shadow: 0 0 0 3px var(--primary-glow); +} + +/* Grid layout of recipes */ +.recipes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 30px; +} + +/* Recipe Card Styling */ +.recipe-card { + background-color: var(--bg-card); + border-radius: var(--border-radius-lg); + border: 1px solid var(--border-color); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: all var(--transition-normal); + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; + position: relative; +} + +.recipe-card:hover { + transform: translateY(-6px); + box-shadow: var(--shadow-md); + border-color: hsl(var(--hue-primary), 40%, 30%); +} + +.recipe-card-img-wrapper { + position: relative; + width: 100%; + padding-top: 68%; /* aspect ratio */ + background-color: var(--border-color); + overflow: hidden; +} + +.recipe-card-img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--transition-slow); +} + +.recipe-card:hover .recipe-card-img { + transform: scale(1.05); +} + +.recipe-card-img-placeholder { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-muted); + gap: 10px; + background: linear-gradient(135deg, var(--bg-card), var(--border-color)); +} + +.recipe-card-img-placeholder i { + width: 48px; + height: 48px; + opacity: 0.5; +} + +.recipe-card-content { + padding: 20px; + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.recipe-card-origin { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--primary); + font-weight: 700; + margin-bottom: 6px; +} + +.recipe-card-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-main); + margin-bottom: 8px; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.recipe-card-desc { + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.5; + margin-bottom: 16px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.recipe-card-meta { + margin-top: auto; + padding-top: 14px; + border-top: 1px solid var(--border-color); + display: flex; + gap: 16px; + font-size: 0.8rem; + color: var(--text-muted); +} + +.recipe-card-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +.recipe-card-meta span i { + width: 14px; + height: 14px; +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 80px 20px; + color: var(--text-muted); + animation: fadeIn var(--transition-normal); +} + +.empty-icon { + background-color: var(--border-color); + width: 80px; + height: 80px; + border-radius: var(--border-radius-full); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 18px; + color: var(--text-muted); +} + +.empty-icon i { + width: 36px; + height: 36px; +} + +.empty-state h3 { + font-size: 1.4rem; + color: var(--text-main); + margin-bottom: 6px; +} + +.empty-state p { + max-width: 340px; + font-size: 0.95rem; + line-height: 1.5; +} + +/* Modal Overlay Framework */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.6); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-normal); + backdrop-filter: blur(4px); + padding: 20px; +} + +.modal-overlay.active { + opacity: 1; + pointer-events: all; +} + +.modal-box { + background-color: var(--bg-card); + border-radius: var(--border-radius-lg); + border: 1px solid var(--border-color); + width: 100%; + max-height: 90vh; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + overflow: hidden; + transform: translateY(30px) scale(0.96); + transition: transform var(--transition-normal); +} + +.modal-overlay.active .modal-box { + transform: translateY(0) scale(1); +} + +.max-width-lg { max-width: 850px; } +.max-width-xl { max-width: 1100px; } +.no-padding { padding: 0; } + +.modal-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h3 { + font-size: 1.4rem; + color: var(--text-main); +} + +.modal-body { + padding: 24px; + overflow-y: auto; + flex-grow: 1; +} + +.modal-footer { + padding: 16px 24px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 12px; + background-color: var(--bg-app); +} + +/* Form Styles inside Modals */ +.form-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.form-grid-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 20px; +} + +@media (max-width: 768px) { + .form-grid-2, .form-grid-3 { + grid-template-columns: 1fr; + } +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + font-size: 0.88rem; + font-weight: 600; + color: var(--text-main); + margin-bottom: 6px; + font-family: var(--font-heading); +} + +.form-help { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 10px; +} + +.form-group input[type="text"], +.form-group input[type="url"], +.form-group textarea { + width: 100%; + padding: 12px 14px; + border-radius: var(--border-radius-md); + border: 1px solid var(--border-color); + background-color: var(--bg-app); + color: var(--text-main); + outline: none; + font-size: 0.95rem; + transition: all var(--transition-fast); +} + +.form-group input[type="text"]:focus, +.form-group input[type="url"]:focus, +.form-group textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-glow); +} + +.custom-img-url-wrapper { + background-color: var(--bg-app); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-md); + padding: 2px; +} + +.custom-img-url-wrapper input { + padding: 10px 10px 10px 42px; + border: none; + outline: none; + background: transparent; + width: 100%; + color: var(--text-main); +} + +/* Image Selection Gallery */ +.image-gallery-selector { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; + margin-bottom: 14px; + max-height: 230px; + overflow-y: auto; + padding: 4px; + background-color: var(--bg-app); + border-radius: var(--border-radius-md); + border: 1px solid var(--border-color); +} + +.image-option { + position: relative; + aspect-ratio: 1; + border-radius: var(--border-radius-sm); + overflow: hidden; + border: 3px solid transparent; + cursor: pointer; + transition: all var(--transition-fast); + background-color: var(--border-color); +} + +.image-option img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.image-option:hover { + transform: scale(0.96); + opacity: 0.9; +} + +.image-option.selected { + border-color: var(--primary); + box-shadow: var(--shadow-sm); +} + +.image-option.selected::after { + content: '✓'; + position: absolute; + top: 4px; + right: 4px; + background-color: var(--primary); + color: var(--text-white); + width: 18px; + height: 18px; + border-radius: var(--border-radius-full); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 700; +} + +/* Interactive Detail Viewer Modal */ +.detail-hero-banner { + height: 380px; + position: relative; + background-size: cover; + background-position: center; + display: flex; + align-items: flex-end; + padding: 30px 40px; +} + +.detail-hero-banner::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.1) 40%, rgba(0,0,0,0.85) 100%); +} + +.detail-back-btn { + position: absolute; + top: 24px; + left: 24px; + display: flex; + align-items: center; + gap: 8px; + background-color: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.15); + backdrop-filter: blur(8px); + color: var(--text-white); + padding: 8px 16px; + border-radius: var(--border-radius-full); + cursor: pointer; + font-family: var(--font-heading); + font-weight: 600; + transition: all var(--transition-fast); +} + +.detail-back-btn:hover { + background-color: rgba(0, 0, 0, 0.7); + transform: translateX(-3px); +} + +.detail-actions { + position: absolute; + top: 24px; + right: 24px; + display: flex; + gap: 12px; +} + +.detail-main-content { + background-color: var(--bg-card); + padding: 40px; + overflow-y: auto; + flex-grow: 1; +} + +@media (max-width: 768px) { + .detail-hero-banner { height: 240px; } + .detail-main-content { padding: 20px; } +} + +.detail-header-info { + margin-bottom: 30px; +} + +#recipe-detail-title { + font-size: 2.2rem; + color: var(--text-main); + margin-bottom: 12px; +} + +.recipe-meta-tags { + display: flex; + gap: 20px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.recipe-meta-tags span { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + color: var(--text-muted); + background-color: var(--bg-app); + padding: 6px 14px; + border-radius: var(--border-radius-full); + border: 1px solid var(--border-color); +} + +.recipe-meta-tags span .val { + font-weight: 600; + color: var(--text-main); +} + +.recipe-meta-tags span i { + width: 16px; + height: 16px; + color: var(--primary); +} + +.recipe-detail-desc { + font-size: 1.05rem; + color: var(--text-muted); + line-height: 1.6; +} + +/* Scaler panel & cooking trigger */ +.servings-scaler-panel { + display: flex; + align-items: center; + background-color: var(--bg-app); + padding: 14px 24px; + border-radius: var(--border-radius-md); + border: 1px solid var(--border-color); + margin-bottom: 35px; + flex-wrap: wrap; + gap: 20px; +} + +.scale-label { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-muted); +} + +.scaler-controls { + display: flex; + align-items: center; + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-full); + padding: 4px; +} + +.scaler-btn { + width: 32px; + height: 32px; + border-radius: var(--border-radius-full); + background: none; + border: none; + color: var(--text-main); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); +} + +.scaler-btn:hover { + background-color: var(--border-color); +} + +.scaler-val { + font-weight: 700; + font-size: 1rem; + padding: 0 14px; + min-width: 44px; + text-align: center; +} + +.cooking-mode-trigger { + margin-left: auto; +} + +@media (max-width: 768px) { + .cooking-mode-trigger { + width: 100%; + margin-left: 0; + } +} + +/* Split layout inside detail modal */ +.detail-split-layout { + display: grid; + grid-template-columns: 2fr 3fr; + gap: 50px; +} + +@media (max-width: 992px) { + .detail-split-layout { + grid-template-columns: 1fr; + gap: 40px; + } +} + +.detail-ingredients-column h3, +.detail-instructions-column h3 { + font-size: 1.4rem; + margin-bottom: 4px; +} + +.section-subtext { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 20px; +} + +/* Ingredients Checklist */ +.ingredients-check-list { + list-style: none; +} + +.ingredient-checkbox-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border-color); + cursor: pointer; +} + +.ingredient-checkbox-item:last-child { + border-bottom: none; +} + +.checkbox-custom { + margin-top: 2px; + width: 20px; + height: 20px; + border-radius: 6px; + border: 2px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + color: transparent; + font-size: 0.75rem; + font-weight: 800; + transition: all var(--transition-fast); + flex-shrink: 0; + background-color: var(--bg-card); +} + +.ingredient-checkbox-item:hover .checkbox-custom { + border-color: var(--primary); +} + +.ingredient-checkbox-item.checked .checkbox-custom { + background-color: var(--primary); + border-color: var(--primary); + color: var(--text-white); +} + +.ingredient-text { + font-size: 0.98rem; + line-height: 1.5; + color: var(--text-main); + transition: all var(--transition-fast); +} + +.ingredient-checkbox-item.checked .ingredient-text { + color: var(--text-muted); + text-decoration: line-through; +} + +/* Instructions lists */ +.instructions-progress-list { + list-style: none; +} + +.instruction-step-item { + position: relative; + padding-left: 45px; + padding-bottom: 26px; + cursor: pointer; +} + +.instruction-step-item:last-child { + padding-bottom: 0; +} + +/* Connector Line */ +.instruction-step-item::after { + content: ''; + position: absolute; + left: 17px; + top: 36px; + bottom: 0; + width: 2px; + background-color: var(--border-color); + z-index: 1; +} + +.instruction-step-item:last-child::after { + display: none; +} + +.step-number-bullet { + position: absolute; + left: 0; + top: 2px; + width: 36px; + height: 36px; + border-radius: var(--border-radius-full); + background-color: var(--bg-app); + border: 2px solid var(--border-color); + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-heading); + font-weight: 700; + font-size: 0.9rem; + z-index: 2; + transition: all var(--transition-fast); +} + +.instruction-step-item:hover .step-number-bullet { + border-color: var(--primary); + color: var(--primary); +} + +.instruction-step-item.active .step-number-bullet { + border-color: var(--primary); + background-color: var(--primary); + color: var(--text-white); + box-shadow: 0 0 0 4px var(--primary-glow); +} + +.instruction-step-item.checked .step-number-bullet { + border-color: var(--accent); + background-color: var(--accent); + color: var(--text-white); +} + +.step-text-block { + font-size: 1rem; + line-height: 1.6; + color: var(--text-main); + transition: all var(--transition-fast); +} + +.instruction-step-item.checked .step-text-block { + color: var(--text-muted); +} + +/* Header style instructions (e.g. ### Sauce) */ +.instruction-step-item.section-header-step { + padding-left: 0; + padding-top: 15px; + padding-bottom: 12px; + cursor: default; +} + +.instruction-step-item.section-header-step::after { + left: 0; +} + +.instruction-step-item.section-header-step .step-text-block { + font-family: var(--font-heading); + font-weight: 700; + font-size: 1.2rem; + color: var(--primary); +} + +.instruction-step-item.section-header-step .step-number-bullet { + display: none; +} + +/* Fullscreen Cooking Mode */ +.cooking-mode-overlay { + background-color: hsl(var(--hue-neutral), 40%, 6%); + padding: 0; +} + +.cooking-container { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + color: var(--text-white); +} + +.cooking-header { + padding: 20px 40px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: space-between; +} + +.cooking-brand { + display: flex; + align-items: center; + gap: 8px; + color: var(--primary); + font-family: var(--font-heading); + font-weight: 700; + font-size: 1.1rem; +} + +.cooking-title-indicator { + font-weight: 600; + font-size: 1rem; + opacity: 0.8; + max-width: 30%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Stopwatch inside Cooking mode */ +.cook-timer-panel { + display: flex; + align-items: center; + gap: 12px; + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 6px 16px; + border-radius: var(--border-radius-full); +} + +.timer-display { + font-family: monospace; + font-size: 1.15rem; + font-weight: 700; + color: var(--primary); +} + +.timer-btn { + background: none; + border: none; + color: var(--text-white); + cursor: pointer; + width: 26px; + height: 26px; + border-radius: var(--border-radius-full); + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); +} + +.timer-btn:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.color-white { + color: var(--text-white) !important; +} + +/* Large Stepper card for focused view */ +.cooking-stepper-box { + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 900px; + width: 90%; + margin: 0 auto; + padding: 40px 0; +} + +.step-progress-track { + width: 100%; + height: 6px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: var(--border-radius-full); + overflow: hidden; + margin-bottom: 50px; +} + +.step-progress-bar { + height: 100%; + background-color: var(--primary); + width: 20%; + border-radius: var(--border-radius-full); + transition: width var(--transition-normal); +} + +.cooking-slide { + text-align: center; + animation: slideIn var(--transition-normal) ease-out; +} + +.cooking-step-badge { + background: var(--primary-glow); + color: var(--primary); + border: 1px solid rgba(245, 158, 11, 0.2); + padding: 8px 20px; + border-radius: var(--border-radius-full); + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.05em; + font-family: var(--font-heading); + text-transform: uppercase; + display: inline-block; + margin-bottom: 30px; +} + +.cooking-step-text { + font-size: 2.2rem; + line-height: 1.5; + font-weight: 500; + max-width: 800px; + margin: 0 auto; +} + +@media (max-width: 768px) { + .cooking-step-text { font-size: 1.6rem; } + .cooking-header { padding: 15px 20px; } +} + +.cooking-controls { + padding: 30px 40px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: space-between; +} + +.step-dots { + display: flex; + gap: 8px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: var(--border-radius-full); + background-color: rgba(255, 255, 255, 0.15); + cursor: pointer; + transition: all var(--transition-fast); +} + +.dot.active { + background-color: var(--primary); + transform: scale(1.3); +} + +/* Full Loading / Scraping Overlay */ +.loading-overlay { + background-color: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(8px); +} + +.loader-card { + background-color: var(--bg-card); + border-radius: var(--border-radius-lg); + border: 1px solid var(--border-color); + padding: 40px; + text-align: center; + max-width: 440px; + width: 90%; + box-shadow: var(--shadow-lg); + animation: scaleUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.spinner-outer { + position: relative; + width: 90px; + height: 90px; + margin: 0 auto 24px auto; +} + +.spinner-inner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: var(--border-radius-full); + border: 4px solid var(--border-color); + border-top-color: var(--primary); + border-bottom-color: var(--secondary); + animation: spin 1.2s linear infinite; +} + +.spinner-logo { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 32px; + height: 32px; + color: var(--primary); +} + +.loader-card h3 { + font-size: 1.4rem; + color: var(--text-main); + margin-bottom: 8px; +} + +.loader-subtitle { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 28px; +} + +.loader-steps { + list-style: none; + text-align: left; + display: flex; + flex-direction: column; + gap: 12px; +} + +.loader-step { + display: flex; + align-items: center; + gap: 12px; + font-size: 0.92rem; + color: var(--text-muted); + padding: 6px 12px; + border-radius: var(--border-radius-sm); + background-color: transparent; + transition: all var(--transition-fast); +} + +.loader-step i { + width: 18px; + height: 18px; +} + +.loader-step.active { + color: var(--primary); + background-color: var(--bg-app); + font-weight: 600; +} + +.loader-step.completed { + color: var(--accent); +} + +.loader-step.completed i { + color: var(--accent); +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scaleUp { + from { transform: scale(0.92); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +@keyframes slideIn { + from { transform: translateY(12px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.spinning-icon { + animation: spin 2s linear infinite; +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..87b63b1 --- /dev/null +++ b/server.js @@ -0,0 +1,551 @@ +const express = require('express'); +const axios = require('axios'); +const cheerio = require('cheerio'); +const cors = require('cors'); +const fs = require('fs'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Setup directories for persistence +const DATA_DIR = path.join(__dirname, 'data'); +const DATA_FILE = path.join(DATA_DIR, 'recipes.json'); + +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} +if (!fs.existsSync(DATA_FILE)) { + fs.writeFileSync(DATA_FILE, JSON.stringify([], null, 2)); +} + +app.use(cors()); +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +// Helper: Parse ISO 8601 Durations (e.g. PT1H30M to "1 hr 30 mins") +function parseISODuration(duration) { + if (!duration || typeof duration !== 'string') return ''; + const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return duration; + const hours = match[1] ? parseInt(match[1], 10) : 0; + const minutes = match[2] ? parseInt(match[2], 10) : 0; + + let result = ''; + if (hours > 0) result += `${hours} hr${hours > 1 ? 's' : ''} `; + if (minutes > 0) result += `${minutes} min${minutes > 1 ? 's' : ''}`; + return result.trim() || duration; +} + +// Helper: Extract recipe instructions from schema format +function extractInstructions(instructions) { + if (!instructions) return []; + if (typeof instructions === 'string') { + return instructions.split(/\r?\n/).map(s => s.trim()).filter(Boolean); + } + if (Array.isArray(instructions)) { + const result = []; + for (const inst of instructions) { + if (typeof inst === 'string') { + result.push(inst.trim()); + } else if (inst && typeof inst === 'object') { + if (inst['@type'] === 'HowToStep') { + result.push(inst.text || inst.name || ''); + } else if (inst['@type'] === 'HowToSection' && Array.isArray(inst.itemListElement)) { + if (inst.name) { + result.push(`### ${inst.name}`); // Prefix section names to identify them in the frontend + } + for (const step of inst.itemListElement) { + if (typeof step === 'string') { + result.push(step.trim()); + } else if (step && typeof step === 'object') { + result.push(step.text || step.name || ''); + } + } + } else if (inst.text) { + result.push(inst.text); + } else if (inst.name) { + result.push(inst.name); + } + } + } + return result.filter(Boolean); + } + return []; +} + +// Helper: Normalize image resources from JSON-LD schema +function normalizeImages(imageObj) { + if (!imageObj) return []; + if (typeof imageObj === 'string') return [imageObj]; + if (Array.isArray(imageObj)) { + return imageObj.map(img => (typeof img === 'string' ? img : img.url)).filter(Boolean); + } + if (typeof imageObj === 'object' && imageObj.url) { + return [imageObj.url]; + } + return []; +} + +// Helper: Retrieve saved recipes +function readRecipes() { + try { + const data = fs.readFileSync(DATA_FILE, 'utf8'); + return JSON.parse(data); + } catch (e) { + console.error('Error reading recipes database:', e); + return []; + } +} + +// Helper: Save recipes to storage +function writeRecipes(recipes) { + try { + fs.writeFileSync(DATA_FILE, JSON.stringify(recipes, null, 2), 'utf8'); + return true; + } catch (e) { + console.error('Error writing recipes database:', e); + return false; + } +} + +// API: List saved recipes +app.get('/api/recipes', (req, res) => { + const recipes = readRecipes(); + res.json(recipes); +}); + +// API: Save new recipe +app.post('/api/recipes', (req, res) => { + const recipes = readRecipes(); + const newRecipe = { + id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5), + title: req.body.title || 'Untitled Recipe', + description: req.body.description || '', + ingredients: Array.isArray(req.body.ingredients) ? req.body.ingredients : [], + instructions: Array.isArray(req.body.instructions) ? req.body.instructions : [], + servings: req.body.servings || '', + prepTime: req.body.prepTime || '', + cookTime: req.body.cookTime || '', + totalTime: req.body.totalTime || '', + image: req.body.image || '', + images: Array.isArray(req.body.images) ? req.body.images : [], + sourceUrl: req.body.sourceUrl || '', + createdAt: new Date().toISOString() + }; + + recipes.push(newRecipe); + writeRecipes(recipes); + res.status(201).json(newRecipe); +}); + +// API: Update recipe +app.put('/api/recipes/:id', (req, res) => { + const recipes = readRecipes(); + const index = recipes.findIndex(r => r.id === req.params.id); + + if (index === -1) { + return res.status(404).json({ error: 'Recipe not found' }); + } + + const updatedRecipe = { + ...recipes[index], + title: req.body.title || recipes[index].title, + description: req.body.description !== undefined ? req.body.description : recipes[index].description, + ingredients: Array.isArray(req.body.ingredients) ? req.body.ingredients : recipes[index].ingredients, + instructions: Array.isArray(req.body.instructions) ? req.body.instructions : recipes[index].instructions, + servings: req.body.servings !== undefined ? req.body.servings : recipes[index].servings, + prepTime: req.body.prepTime !== undefined ? req.body.prepTime : recipes[index].prepTime, + cookTime: req.body.cookTime !== undefined ? req.body.cookTime : recipes[index].cookTime, + totalTime: req.body.totalTime !== undefined ? req.body.totalTime : recipes[index].totalTime, + image: req.body.image !== undefined ? req.body.image : recipes[index].image, + images: Array.isArray(req.body.images) ? req.body.images : recipes[index].images, + sourceUrl: req.body.sourceUrl !== undefined ? req.body.sourceUrl : recipes[index].sourceUrl, + updatedAt: new Date().toISOString() + }; + + recipes[index] = updatedRecipe; + writeRecipes(recipes); + res.json(updatedRecipe); +}); + +// API: Delete recipe +app.delete('/api/recipes/:id', (req, res) => { + let recipes = readRecipes(); + const filtered = recipes.filter(r => r.id !== req.params.id); + + if (recipes.length === filtered.length) { + return res.status(404).json({ error: 'Recipe not found' }); + } + + writeRecipes(filtered); + res.json({ success: true }); +}); + +// API: Scrape Recipe from Link +app.post('/api/scrape', async (req, res) => { + const { url } = req.body; + if (!url) { + return res.status(400).json({ error: 'URL is required' }); + } + + try { + const response = await axios.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + 'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Upgrade-Insecure-Requests': '1' + }, + timeout: 10000 + }); + + const html = response.data; + const $ = cheerio.load(html); + + // Initial search: Extract JSON-LD structures + let schemaRecipe = null; + $('script[type="application/ld+json"]').each((i, el) => { + try { + const text = $(el).html(); + if (!text) return; + const data = JSON.parse(text); + + const searchForRecipe = (obj) => { + if (!obj || typeof obj !== 'object') return; + if (Array.isArray(obj)) { + for (const item of obj) { + searchForRecipe(item); + if (schemaRecipe) return; + } + } else { + const type = obj['@type']; + if (type === 'Recipe' || (Array.isArray(type) && type.includes('Recipe'))) { + schemaRecipe = obj; + return; + } + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + searchForRecipe(obj[key]); + if (schemaRecipe) return; + } + } + } + }; + + searchForRecipe(data); + } catch (e) { + // Safe to ignore malformed JSON script elements + } + }); + + // Populate structured data object + let recipeData = { + title: '', + description: '', + ingredients: [], + instructions: [], + servings: '', + prepTime: '', + cookTime: '', + totalTime: '', + images: [], + sourceUrl: url + }; + + // Scrape candidate image arrays from tags to enable user choice + const extractedImages = []; + $('img').each((i, el) => { + const src = $(el).attr('src') || $(el).attr('data-src') || $(el).attr('data-lazy-src'); + if (src && src.startsWith('http')) { + // Skip tracking pixels, tiny graphics or profile avatars + const width = $(el).attr('width'); + const height = $(el).attr('height'); + const isSmall = (width && parseInt(width, 10) < 150) || (height && parseInt(height, 10) < 150); + const containsTrashKeywords = /avatar|logo|icon|spacer|ad-|banner|pixel|tracking/i.test(src); + + if (!isSmall && !containsTrashKeywords && !extractedImages.includes(src)) { + extractedImages.push(src); + } + } + }); + + if (schemaRecipe) { + // Standard schema-based extraction + recipeData.title = schemaRecipe.name || $('title').text().trim() || ''; + recipeData.description = schemaRecipe.description || ''; + + // Servings + if (schemaRecipe.recipeYield) { + recipeData.servings = Array.isArray(schemaRecipe.recipeYield) + ? schemaRecipe.recipeYield[0] + : schemaRecipe.recipeYield.toString(); + } + + // Times + recipeData.prepTime = parseISODuration(schemaRecipe.prepTime || ''); + recipeData.cookTime = parseISODuration(schemaRecipe.cookTime || ''); + recipeData.totalTime = parseISODuration(schemaRecipe.totalTime || ''); + + // Ingredients (Support singular and plural, also support plain strings) + const rawIngredients = schemaRecipe.recipeIngredient || schemaRecipe.recipeIngredients || []; + if (typeof rawIngredients === 'string') { + recipeData.ingredients = rawIngredients.split(/\r?\n/).map(i => i.trim()).filter(Boolean); + } else if (Array.isArray(rawIngredients)) { + recipeData.ingredients = rawIngredients; + } + + // Instructions (Support singular and plural) + recipeData.instructions = extractInstructions(schemaRecipe.recipeInstructions || schemaRecipe.recipeInstruction); + + // Schema Image fallback + const schemaImages = normalizeImages(schemaRecipe.image); + schemaImages.forEach(img => { + if (!extractedImages.includes(img)) { + extractedImages.unshift(img); // Prioritize JSON-LD schema images at the start + } + }); + } else { + // Fallback Scraping strategies if JSON-LD is missing + recipeData.title = $('meta[property="og:title"]').attr('content') || $('title').text().trim() || ''; + recipeData.description = $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content') || ''; + + const ogImg = $('meta[property="og:image"]').attr('content'); + if (ogImg && !extractedImages.includes(ogImg)) { + extractedImages.unshift(ogImg); + } + + // Parse ingredients structurally + const ingredientSelectors = [ + '.wprm-recipe-ingredient', + '.tasty-recipes-ingredients li', + '.tasty-recipe-ingredients li', + '.recipe-ingredients li', + '.ingredients-list li', + '.ingredients li', + '[class*="ingredients"] li', + '[class*="ingredients"] p', + '[class*="ingredient"] li', + '[class*="ingredient"] p', + 'ul[class*="ingredient"] li', + 'ol[class*="ingredient"] li', + '.cooked-ingredient', + '.recipe-ingredients-wrap li' + ]; + + for (const selector of ingredientSelectors) { + const items = $(selector); + if (items.length > 1) { + items.each((i, el) => { + const txt = $(el).text().trim().replace(/\s+/g, ' '); + if (txt && !recipeData.ingredients.includes(txt)) { + recipeData.ingredients.push(txt); + } + }); + if (recipeData.ingredients.length > 1) break; + } + } + + // Parse instructions structurally + const instructionSelectors = [ + '.wprm-recipe-instruction', + '.tasty-recipes-instructions li', + '.tasty-recipe-instructions li', + '.recipe-directions li', + '.recipe-instructions li', + '.instructions-list li', + '.instructions li', + '.recipe-steps li', + '[class*="instructions"] li', + '[class*="instructions"] p', + '[class*="directions"] li', + '[class*="directions"] p', + '[class*="step"] li', + '[class*="step"] p', + '.cooked-step', + '.recipe-method li', + '.recipe-method p', + '.recipe-preparation li' + ]; + + for (const selector of instructionSelectors) { + const items = $(selector); + if (items.length > 1) { + items.each((i, el) => { + const txt = $(el).text().trim().replace(/\s+/g, ' '); + if (txt && !recipeData.instructions.includes(txt)) { + recipeData.instructions.push(txt); + } + }); + if (recipeData.instructions.length > 1) break; + } + } + } + + // Heuristics Fallback: Heading Sibling crawl if still empty + if (recipeData.ingredients.length === 0) { + $('h1, h2, h3, h4, h5').each((i, heading) => { + const text = $(heading).text().toLowerCase(); + if (text.includes('ingredients') && !text.includes('instruction') && !text.includes('direction')) { + let next = $(heading).next(); + if (next.length === 0) { + next = $(heading).parent().next(); + } + + let limit = 0; + while (next.length > 0 && limit < 15) { + if (/h[1-6]/i.test(next[0].tagName)) break; + + const items = next.find('li, p'); + if (items.length > 0) { + items.each((j, el) => { + const itemText = $(el).text().trim().replace(/\s+/g, ' '); + if (itemText && itemText.length > 2 && itemText.length < 200 && !recipeData.ingredients.includes(itemText)) { + recipeData.ingredients.push(itemText); + } + }); + } else { + const itemText = next.text().trim().replace(/\s+/g, ' '); + if (itemText && itemText.length > 2 && itemText.length < 200) { + recipeData.ingredients.push(itemText); + } + } + next = next.next(); + limit++; + } + if (recipeData.ingredients.length > 2) return false; // Break heading loop + } + }); + } + + if (recipeData.instructions.length === 0) { + $('h1, h2, h3, h4, h5').each((i, heading) => { + const text = $(heading).text().toLowerCase(); + if (/instruction|direction|method|preparation|steps/i.test(text) && !text.includes('ingredient')) { + let next = $(heading).next(); + if (next.length === 0) { + next = $(heading).parent().next(); + } + + let limit = 0; + while (next.length > 0 && limit < 15) { + if (/h[1-6]/i.test(next[0].tagName)) break; + + const items = next.find('li, p'); + if (items.length > 0) { + items.each((j, el) => { + const itemText = $(el).text().trim().replace(/\s+/g, ' '); + if (itemText && itemText.length > 5 && itemText.length < 1000 && !recipeData.instructions.includes(itemText)) { + recipeData.instructions.push(itemText); + } + }); + } else { + const itemText = next.text().trim().replace(/\s+/g, ' '); + if (itemText && itemText.length > 5 && itemText.length < 1000) { + recipeData.instructions.push(itemText); + } + } + next = next.next(); + limit++; + } + if (recipeData.instructions.length > 1) return false; // Break heading loop + } + }); + } + + // Heuristics Fallback: Blockquote / Multi-paragraph text classifier (for vintage/informal blogs) + if (recipeData.ingredients.length === 0 && recipeData.instructions.length === 0) { + const isStepText = (text) => { + if (/^(?:step\s*)?\d+[\.\)\-]\s/i.test(text)) return true; + if (/^\d+\s+(?:mix|stir|combine|sift|bake|heat|preheat|fry|drain|serve|boil|chop|pour|place|add|cook|whisk|beat|divide|mould|brush|roll|repeat|prepare|cut)\b/i.test(text)) return true; + if (/^(?:mix|stir|combine|sift|bake|heat|preheat|fry|drain|serve|boil|chop|pour|place|add|cook|whisk|beat|divide|mould|brush|roll|repeat|prepare|cut)\b/i.test(text) && text.length > 25) return true; + return false; + }; + + const isTitleOrHeader = (text) => { + const titleClean = recipeData.title.toLowerCase(); + const textClean = text.toLowerCase(); + if (titleClean.includes(textClean) || textClean.includes(titleClean)) return true; + if (/^(ingredients|instructions|directions|method|results|notes|conclusion)\b/i.test(textClean)) return true; + return false; + }; + + // Prioritize containers: blockquote first (specific card), then entry-content, then article + let selectedContainer = null; + if ($('blockquote').length > 0) { + selectedContainer = $('blockquote'); + } else if ($('.entry-content').length > 0) { + selectedContainer = $('.entry-content'); + } else if ($('article').length > 0) { + selectedContainer = $('article'); + } + + if (selectedContainer) { + selectedContainer.each((i, container) => { + const paragraphs = $(container).find('p'); + if (paragraphs.length > 2) { + paragraphs.each((j, el) => { + const html = $(el).html() || ''; + if (html.includes('
')) { + // Split by BR tags if it's a single block + const lines = html.split(//i) + .map(l => cheerio.load(l).text().trim().replace(/\s+/g, ' ')) + .filter(Boolean); + + for (const line of lines) { + if (isTitleOrHeader(line) || line.length < 3) continue; + if (isStepText(line)) { + recipeData.instructions.push(line); + } else { + recipeData.ingredients.push(line); + } + } + } else { + const txt = $(el).text().trim().replace(/\s+/g, ' '); + if (!txt || isTitleOrHeader(txt) || txt.length < 3) return; + + if (isStepText(txt)) { + recipeData.instructions.push(txt); + } else { + recipeData.ingredients.push(txt); + } + } + }); + if (recipeData.ingredients.length > 1) return false; // Break container loop + } + }); + } + } + + // Clean up lists + recipeData.ingredients = recipeData.ingredients.map(i => i.trim()).filter(Boolean); + recipeData.instructions = recipeData.instructions.map(i => i.trim()).filter(Boolean); + recipeData.images = extractedImages.slice(0, 15); // Send top 15 images to UI for selection + + res.json(recipeData); + } catch (error) { + console.error('Error occurred while scraping URL:', error.message); + const status = error.response ? error.response.status : 500; + const message = error.response && error.response.status === 403 + ? 'This website is protected by Cloudflare bot protection and blocks automated access.' + : error.message; + res.status(status).json({ + error: message, + isBlocked: status === 403 || status === 503 + }); + } +}); + +// Launch server +app.listen(PORT, () => { + console.log(`Recipe Manager server is active at http://localhost:${PORT}`); +});