Add image processing and related warnings system, update import results page
This commit is contained in:
456
inventory-server/package-lock.json
generated
456
inventory-server/package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"openai": "^6.0.0",
|
"openai": "^6.0.0",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"pm2": "^5.3.0",
|
"pm2": "^5.3.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
@@ -30,6 +31,384 @@
|
|||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mapbox/node-pre-gyp": {
|
"node_modules/@mapbox/node-pre-gyp": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||||
@@ -840,6 +1219,19 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1",
|
||||||
|
"color-string": "^1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -858,6 +1250,16 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^1.0.0",
|
||||||
|
"simple-swizzle": "^0.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-support": {
|
"node_modules/color-support": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||||
@@ -1853,6 +2255,12 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arrayish": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -3387,6 +3795,45 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"color": "^4.2.3",
|
||||||
|
"detect-libc": "^2.0.3",
|
||||||
|
"semver": "^7.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.33.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.33.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.0.5",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
|
||||||
|
"@img/sharp-linux-arm": "0.33.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.33.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.33.5",
|
||||||
|
"@img/sharp-linux-x64": "0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||||
|
"@img/sharp-wasm32": "0.33.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.33.5",
|
||||||
|
"@img/sharp-win32-x64": "0.33.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shimmer": {
|
"node_modules/shimmer": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz",
|
||||||
@@ -3471,6 +3918,15 @@
|
|||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-swizzle": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-arrayish": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/simple-update-notifier": {
|
"node_modules/simple-update-notifier": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"openai": "^6.0.0",
|
"openai": "^6.0.0",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"pm2": "^5.3.0",
|
"pm2": "^5.3.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const mysql = require('mysql2/promise');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const fsp = fs.promises;
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
// Create uploads directory if it doesn't exist
|
// Create uploads directory if it doesn't exist
|
||||||
const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
|
const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
|
||||||
@@ -35,6 +37,9 @@ const connectionCache = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MIN_IMAGE_DIMENSION = 1000;
|
||||||
|
const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
// Function to schedule image deletion after 24 hours
|
// Function to schedule image deletion after 24 hours
|
||||||
const scheduleImageDeletion = (filename, filePath) => {
|
const scheduleImageDeletion = (filename, filePath) => {
|
||||||
// Only schedule deletion for images in the products folder
|
// Only schedule deletion for images in the products folder
|
||||||
@@ -145,6 +150,255 @@ const cleanupImagesOnStartup = () => {
|
|||||||
// Run cleanup on server start
|
// Run cleanup on server start
|
||||||
cleanupImagesOnStartup();
|
cleanupImagesOnStartup();
|
||||||
|
|
||||||
|
const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2));
|
||||||
|
|
||||||
|
const processUploadedImage = async (filePath, mimetype) => {
|
||||||
|
const notices = [];
|
||||||
|
const legacyWarnings = [];
|
||||||
|
const metadata = {};
|
||||||
|
|
||||||
|
const originalBuffer = await fsp.readFile(filePath);
|
||||||
|
let baseMetadata = await sharp(originalBuffer, { failOn: 'none' }).metadata();
|
||||||
|
|
||||||
|
metadata.width = baseMetadata.width || 0;
|
||||||
|
metadata.height = baseMetadata.height || 0;
|
||||||
|
metadata.size = originalBuffer.length;
|
||||||
|
metadata.colorSpace = baseMetadata.space || baseMetadata.colourspace || null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
baseMetadata.width &&
|
||||||
|
baseMetadata.height &&
|
||||||
|
(baseMetadata.width < MIN_IMAGE_DIMENSION || baseMetadata.height < MIN_IMAGE_DIMENSION)
|
||||||
|
) {
|
||||||
|
const message = `Image is ${baseMetadata.width}x${baseMetadata.height}. Recommended minimum is ${MIN_IMAGE_DIMENSION}x${MIN_IMAGE_DIMENSION}.`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'warning',
|
||||||
|
code: 'dimensions_too_small',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorSpace = (baseMetadata.space || baseMetadata.colourspace || '').toLowerCase();
|
||||||
|
let shouldConvertToRgb = colorSpace === 'cmyk';
|
||||||
|
|
||||||
|
if (shouldConvertToRgb) {
|
||||||
|
const message = 'Converted image from CMYK to RGB.';
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'info',
|
||||||
|
code: 'converted_to_rgb',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = (baseMetadata.format || '').toLowerCase();
|
||||||
|
if (format === 'gif') {
|
||||||
|
if (metadata.size > MAX_IMAGE_SIZE_BYTES) {
|
||||||
|
const message = `GIF optimization is limited; resulting size is ${bytesToMegabytes(metadata.size)}MB (target 5MB).`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'warning',
|
||||||
|
code: 'gif_size_limit',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
metadata.convertedToRgb = false;
|
||||||
|
metadata.resized = false;
|
||||||
|
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format);
|
||||||
|
let targetQuality = supportsQuality ? 90 : undefined;
|
||||||
|
let finalQuality = undefined;
|
||||||
|
|
||||||
|
let currentWidth = baseMetadata.width || null;
|
||||||
|
let currentHeight = baseMetadata.height || null;
|
||||||
|
|
||||||
|
let resized = false;
|
||||||
|
let mutated = false;
|
||||||
|
let finalBuffer = originalBuffer;
|
||||||
|
let finalInfo = baseMetadata;
|
||||||
|
|
||||||
|
const encode = async ({ width, height, quality }) => {
|
||||||
|
let pipeline = sharp(originalBuffer, { failOn: 'none' });
|
||||||
|
|
||||||
|
if (shouldConvertToRgb) {
|
||||||
|
pipeline = pipeline.toColorspace('srgb');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width || height) {
|
||||||
|
pipeline = pipeline.resize({
|
||||||
|
width: width ?? undefined,
|
||||||
|
height: height ?? undefined,
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'png':
|
||||||
|
pipeline = pipeline.png({
|
||||||
|
compressionLevel: 9,
|
||||||
|
adaptiveFiltering: true,
|
||||||
|
palette: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'webp':
|
||||||
|
pipeline = pipeline.webp({ quality: quality ?? 90 });
|
||||||
|
break;
|
||||||
|
case 'jpeg':
|
||||||
|
case 'jpg':
|
||||||
|
default:
|
||||||
|
pipeline = pipeline.jpeg({ quality: quality ?? 90, mozjpeg: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pipeline.toBuffer({ resolveWithObject: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const canResize =
|
||||||
|
(currentWidth && currentWidth > MIN_IMAGE_DIMENSION) ||
|
||||||
|
(currentHeight && currentHeight > MIN_IMAGE_DIMENSION);
|
||||||
|
|
||||||
|
if (metadata.size > MAX_IMAGE_SIZE_BYTES && (supportsQuality || canResize)) {
|
||||||
|
const maxAttempts = 8;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
let targetWidth = currentWidth;
|
||||||
|
let targetHeight = currentHeight;
|
||||||
|
let resizedThisAttempt = false;
|
||||||
|
|
||||||
|
if (currentWidth && currentWidth > MIN_IMAGE_DIMENSION) {
|
||||||
|
targetWidth = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentWidth * 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentHeight && currentHeight > MIN_IMAGE_DIMENSION) {
|
||||||
|
targetHeight = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentHeight * 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(targetWidth && currentWidth && targetWidth < currentWidth) ||
|
||||||
|
(targetHeight && currentHeight && targetHeight < currentHeight)
|
||||||
|
) {
|
||||||
|
resized = true;
|
||||||
|
resizedThisAttempt = true;
|
||||||
|
currentWidth = targetWidth;
|
||||||
|
currentHeight = targetHeight;
|
||||||
|
} else if (!supportsQuality || (targetQuality && targetQuality <= 70)) {
|
||||||
|
// Cannot resize further and quality cannot be adjusted
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityForAttempt = supportsQuality ? targetQuality : undefined;
|
||||||
|
const { data, info } = await encode({
|
||||||
|
width: currentWidth,
|
||||||
|
height: currentHeight,
|
||||||
|
quality: qualityForAttempt,
|
||||||
|
});
|
||||||
|
|
||||||
|
mutated = true;
|
||||||
|
finalBuffer = data;
|
||||||
|
finalInfo = info;
|
||||||
|
metadata.optimizedSize = data.length;
|
||||||
|
if (info.width) metadata.width = info.width;
|
||||||
|
if (info.height) metadata.height = info.height;
|
||||||
|
if (info.width) currentWidth = info.width;
|
||||||
|
if (info.height) currentHeight = info.height;
|
||||||
|
|
||||||
|
if (supportsQuality && qualityForAttempt) {
|
||||||
|
finalQuality = qualityForAttempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length <= MAX_IMAGE_SIZE_BYTES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resizedThisAttempt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsQuality && targetQuality && targetQuality > 70) {
|
||||||
|
const nextQuality = Math.max(70, targetQuality - 10);
|
||||||
|
if (nextQuality === targetQuality) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
targetQuality = nextQuality;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalBuffer.length > MAX_IMAGE_SIZE_BYTES) {
|
||||||
|
const message = `Optimized image remains ${bytesToMegabytes(finalBuffer.length)}MB (target 5MB).`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'warning',
|
||||||
|
code: 'size_over_limit',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
} else if (shouldConvertToRgb) {
|
||||||
|
const { data, info } = await encode({ width: currentWidth, height: currentHeight });
|
||||||
|
mutated = true;
|
||||||
|
finalBuffer = data;
|
||||||
|
finalInfo = info;
|
||||||
|
metadata.optimizedSize = data.length;
|
||||||
|
if (info.width) metadata.width = info.width;
|
||||||
|
if (info.height) metadata.height = info.height;
|
||||||
|
if (info.width) currentWidth = info.width;
|
||||||
|
if (info.height) currentHeight = info.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutated) {
|
||||||
|
await fsp.writeFile(filePath, finalBuffer);
|
||||||
|
metadata.optimizedSize = finalBuffer.length;
|
||||||
|
} else {
|
||||||
|
// No transformation occurred; still need to ensure we report original stats
|
||||||
|
metadata.optimizedSize = metadata.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.convertedToRgb = shouldConvertToRgb && mutated;
|
||||||
|
metadata.resized = resized;
|
||||||
|
if (finalQuality) {
|
||||||
|
metadata.quality = finalQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resized && metadata.width && metadata.height) {
|
||||||
|
const message = `Image resized to ${metadata.width}x${metadata.height} during optimization.`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'info',
|
||||||
|
code: 'resized',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalQuality && finalQuality < 90) {
|
||||||
|
const message = `Image quality adjusted to ${finalQuality} to reduce file size.`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'info',
|
||||||
|
code: 'quality_adjusted',
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
notices,
|
||||||
|
warnings: legacyWarnings,
|
||||||
|
metadata,
|
||||||
|
finalSize: finalBuffer.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Configure multer for file uploads
|
// Configure multer for file uploads
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
@@ -178,7 +432,7 @@ const storage = multer.diskStorage({
|
|||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: storage,
|
storage: storage,
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 5 * 1024 * 1024, // 5MB max file size
|
fileSize: 15 * 1024 * 1024, // Allow bigger uploads; processing will reduce to 5MB
|
||||||
},
|
},
|
||||||
fileFilter: function (req, file, cb) {
|
fileFilter: function (req, file, cb) {
|
||||||
// Accept only image files
|
// Accept only image files
|
||||||
@@ -345,7 +599,7 @@ async function setupSshTunnel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Image upload endpoint
|
// Image upload endpoint
|
||||||
router.post('/upload-image', upload.single('image'), (req, res) => {
|
router.post('/upload-image', upload.single('image'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No image file provided' });
|
return res.status(400).json({ error: 'No image file provided' });
|
||||||
@@ -375,6 +629,10 @@ router.post('/upload-image', upload.single('image'), (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Process the image (resize/compress/color-space) before responding
|
||||||
|
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
|
||||||
|
req.file.size = processingResult.finalSize;
|
||||||
|
|
||||||
// Create URL for the uploaded file - using an absolute URL with domain
|
// Create URL for the uploaded file - using an absolute URL with domain
|
||||||
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
|
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
|
||||||
const baseUrl = 'https://acot.site';
|
const baseUrl = 'https://acot.site';
|
||||||
@@ -390,11 +648,24 @@ router.post('/upload-image', upload.single('image'), (req, res) => {
|
|||||||
fileName: req.file.filename,
|
fileName: req.file.filename,
|
||||||
mimetype: req.file.mimetype,
|
mimetype: req.file.mimetype,
|
||||||
fullPath: filePath,
|
fullPath: filePath,
|
||||||
|
notices: processingResult.notices,
|
||||||
|
warnings: processingResult.warnings,
|
||||||
|
metadata: processingResult.metadata,
|
||||||
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
|
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading image:', error);
|
console.error('Error uploading image:', error);
|
||||||
|
if (req?.file?.filename) {
|
||||||
|
const cleanupPath = path.join(uploadsDir, req.file.filename);
|
||||||
|
if (fs.existsSync(cleanupPath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(cleanupPath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error('Failed to remove file after processing error:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
res.status(500).json({ error: error.message || 'Failed to upload image' });
|
res.status(500).json({ error: error.message || 'Failed to upload image' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,16 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Loader2, Trash2, Maximize2, GripVertical, X } from "lucide-react";
|
import { Loader2, Trash2, Maximize2, GripVertical, X, Info } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import type { ProductImageSortable } from "../../types";
|
||||||
|
|
||||||
// Define the ProductImage interface
|
type SortableImage = ProductImageSortable & { url?: string };
|
||||||
interface ProductImage {
|
|
||||||
id: string;
|
|
||||||
url?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
fileName?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
isLoading?: boolean;
|
|
||||||
// Optional fields from the full ProductImage type
|
|
||||||
productIndex?: number;
|
|
||||||
pid?: number;
|
|
||||||
iid?: number;
|
|
||||||
type?: number;
|
|
||||||
order?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
hidden?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the SortableImageProps interface
|
|
||||||
interface SortableImageProps {
|
interface SortableImageProps {
|
||||||
image: ProductImage;
|
image: SortableImage;
|
||||||
productIndex: number;
|
productIndex: number;
|
||||||
imgIndex: number;
|
imgIndex: number;
|
||||||
productName?: string; // Make this optional
|
productName?: string; // Make this optional
|
||||||
@@ -55,6 +39,11 @@ export const SortableImage = ({
|
|||||||
removeImage
|
removeImage
|
||||||
}: SortableImageProps) => {
|
}: SortableImageProps) => {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const notices = image.notices ?? [];
|
||||||
|
const attentionNotices = notices.filter((notice) => notice.level === 'warning');
|
||||||
|
const infoNotices = notices.filter((notice) => notice.level === 'info');
|
||||||
|
const hasAttention = attentionNotices.length > 0;
|
||||||
|
const metadata = image.metadata;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@@ -144,6 +133,40 @@ export const SortableImage = ({
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{hasAttention && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="absolute bottom-1 right-1 z-10 flex items-center rounded-full bg-sky-500/90 p-1 text-white shadow-sm">
|
||||||
|
<Info className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="end" className="max-w-[240px] text-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{attentionNotices.map((notice, idx) => (
|
||||||
|
<p key={`warn-${idx}`}>{notice.message}</p>
|
||||||
|
))}
|
||||||
|
{infoNotices.map((notice, idx) => (
|
||||||
|
<p key={`info-${idx}`} className="text-muted-foreground">
|
||||||
|
{notice.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{metadata?.width && metadata?.height && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Detected size: {metadata.width}×{metadata.height}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{metadata?.optimizedSize && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Optimized size: {(metadata.optimizedSize / (1024 * 1024)).toFixed(2)}MB
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -195,4 +218,4 @@ export const SortableImage = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,92 @@
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { Product, ProductImageSortable } from "../types";
|
import { Product, ProductImageSortable, ImageMetadata, ImageNotice } from "../types";
|
||||||
|
|
||||||
|
const MIN_DIMENSION = 1000;
|
||||||
|
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const readImageMetadata = async (file: File): Promise<ImageMetadata> => {
|
||||||
|
try {
|
||||||
|
if (typeof createImageBitmap === "function") {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
return {
|
||||||
|
width: bitmap.width,
|
||||||
|
height: bitmap.height,
|
||||||
|
size: file.size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("createImageBitmap failed, falling back to Image element", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const image = new Image();
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
image.onload = () => {
|
||||||
|
resolve({
|
||||||
|
width: image.naturalWidth,
|
||||||
|
height: image.naturalHeight,
|
||||||
|
size: file.size
|
||||||
|
});
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
image.onerror = () => {
|
||||||
|
resolve({ size: file.size });
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
image.src = objectUrl;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyzeImage = async (file: File) => {
|
||||||
|
const metadata = await readImageMetadata(file);
|
||||||
|
const notices: ImageNotice[] = [];
|
||||||
|
|
||||||
|
if (metadata.width && metadata.height) {
|
||||||
|
if (metadata.width < MIN_DIMENSION || metadata.height < MIN_DIMENSION) {
|
||||||
|
notices.push({
|
||||||
|
message: `Image is ${metadata.width}x${metadata.height}. Recommended minimum is ${MIN_DIMENSION}x${MIN_DIMENSION}.`,
|
||||||
|
level: 'warning',
|
||||||
|
code: 'dimensions_too_small',
|
||||||
|
source: 'client'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notices.push({
|
||||||
|
message: "Unable to verify image dimensions.",
|
||||||
|
level: 'info',
|
||||||
|
code: 'dimensions_unknown',
|
||||||
|
source: 'client'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||||
|
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
|
||||||
|
notices.push({
|
||||||
|
message: `Image is ${sizeMb}MB. Files above 5MB will be optimized automatically.`,
|
||||||
|
level: 'info',
|
||||||
|
code: 'client_large_file',
|
||||||
|
source: 'client'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { metadata, notices };
|
||||||
|
};
|
||||||
|
|
||||||
|
const dedupeNotices = (notices: ImageNotice[] = []) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return notices.filter((notice) => {
|
||||||
|
const key = `${notice.level}:${notice.code ?? ''}:${notice.message}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface UseProductImageOperationsProps {
|
interface UseProductImageOperationsProps {
|
||||||
data: Product[];
|
data: Product[];
|
||||||
@@ -72,10 +158,12 @@ export const useProductImageOperations = ({
|
|||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
|
const imageId = `image-${productIndex}-${Date.now()}-${i}`;
|
||||||
|
const productLabel = data[productIndex].name || `Product #${productIndex + 1}`;
|
||||||
|
|
||||||
// Add placeholder for this image
|
// Add placeholder for this image
|
||||||
const newImage: ProductImageSortable = {
|
const newImage: ProductImageSortable = {
|
||||||
id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID
|
id: imageId,
|
||||||
productIndex,
|
productIndex,
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -91,6 +179,48 @@ export const useProductImageOperations = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
setProductImages(prev => [...prev, newImage]);
|
setProductImages(prev => [...prev, newImage]);
|
||||||
|
|
||||||
|
let analysisNotices: ImageNotice[] | undefined;
|
||||||
|
let metadata: ImageMetadata | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const analysis = await analyzeImage(file);
|
||||||
|
metadata = analysis.metadata;
|
||||||
|
analysisNotices = analysis.notices?.length ? dedupeNotices(analysis.notices) : undefined;
|
||||||
|
|
||||||
|
if (analysisNotices?.length) {
|
||||||
|
const warningMessages = analysisNotices
|
||||||
|
.filter((notice) => notice.level === 'warning')
|
||||||
|
.map((notice) => notice.message);
|
||||||
|
const infoMessages = analysisNotices
|
||||||
|
.filter((notice) => notice.level === 'info')
|
||||||
|
.map((notice) => notice.message);
|
||||||
|
|
||||||
|
if (warningMessages.length) {
|
||||||
|
toast.warning(`${file.name}: ${warningMessages.join(" ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoMessages.length) {
|
||||||
|
if (typeof toast.info === 'function') {
|
||||||
|
toast.info(`${file.name}: ${infoMessages.join(" ")}`);
|
||||||
|
} else {
|
||||||
|
toast(`${file.name}`, {
|
||||||
|
description: infoMessages.join(" "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProductImages(prev =>
|
||||||
|
prev.map(img =>
|
||||||
|
img.id === imageId
|
||||||
|
? { ...img, metadata, notices: analysisNotices }
|
||||||
|
: img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to analyze image before upload', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Create form data for upload
|
// Create form data for upload
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -111,29 +241,77 @@ export const useProductImageOperations = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
const serverNotices: ImageNotice[] = Array.isArray(result.notices)
|
||||||
|
? result.notices.map((notice: any) => ({
|
||||||
|
message: String(notice.message ?? notice),
|
||||||
|
level: notice.level === 'warning' ? 'warning' : 'info',
|
||||||
|
code: notice.code,
|
||||||
|
source: notice.source ?? 'server'
|
||||||
|
}))
|
||||||
|
: Array.isArray(result.warnings)
|
||||||
|
? result.warnings.map((message: any) => ({
|
||||||
|
message: String(message),
|
||||||
|
level: 'info',
|
||||||
|
source: 'server'
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (serverNotices.length) {
|
||||||
|
const warningMessages = serverNotices
|
||||||
|
.filter((notice) => notice.level === 'warning')
|
||||||
|
.map((notice) => notice.message);
|
||||||
|
const infoMessages = serverNotices
|
||||||
|
.filter((notice) => notice.level === 'info')
|
||||||
|
.map((notice) => notice.message);
|
||||||
|
|
||||||
|
if (warningMessages.length) {
|
||||||
|
toast.warning(`${file.name}: ${warningMessages.join(" ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoMessages.length) {
|
||||||
|
if (typeof toast.info === 'function') {
|
||||||
|
toast.info(`${file.name}: ${infoMessages.join(" ")}`);
|
||||||
|
} else {
|
||||||
|
toast(`${file.name}`, {
|
||||||
|
description: infoMessages.join(" "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update the image URL in our state
|
// Update the image URL in our state
|
||||||
setProductImages(prev =>
|
setProductImages(prev =>
|
||||||
prev.map(img =>
|
prev.map(img => {
|
||||||
(img.loading && img.productIndex === productIndex && img.fileName === file.name)
|
if (img.id !== imageId) return img;
|
||||||
? { ...img, imageUrl: result.imageUrl, loading: false }
|
|
||||||
: img
|
const combinedNotices = dedupeNotices([
|
||||||
)
|
...(analysisNotices || []),
|
||||||
|
...serverNotices
|
||||||
|
]);
|
||||||
|
|
||||||
|
const combinedMetadata: ImageMetadata | undefined = result.metadata
|
||||||
|
? { ...metadata, ...result.metadata }
|
||||||
|
: metadata;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...img,
|
||||||
|
imageUrl: result.imageUrl,
|
||||||
|
loading: false,
|
||||||
|
notices: combinedNotices.length ? combinedNotices : undefined,
|
||||||
|
metadata: combinedMetadata,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the product data with the new image URL
|
// Update the product data with the new image URL
|
||||||
addImageToProduct(productIndex, result.imageUrl);
|
addImageToProduct(productIndex, result.imageUrl);
|
||||||
|
|
||||||
toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
toast.success(`Image uploaded for ${productLabel}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
|
||||||
// Remove the failed image from our state
|
// Remove the failed image from our state
|
||||||
setProductImages(prev =>
|
setProductImages(prev => prev.filter(img => img.id !== imageId));
|
||||||
prev.filter(img =>
|
|
||||||
!(img.loading && img.productIndex === productIndex && img.fileName === file.name)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
@@ -192,4 +370,4 @@ export const useProductImageOperations = ({
|
|||||||
handleImageUpload,
|
handleImageUpload,
|
||||||
removeImage,
|
removeImage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export type ProductImage = {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
notices?: ImageNotice[];
|
||||||
|
metadata?: ImageMetadata;
|
||||||
// Schema fields
|
// Schema fields
|
||||||
pid: number;
|
pid: number;
|
||||||
iid: number;
|
iid: number;
|
||||||
@@ -13,6 +15,26 @@ export type ProductImage = {
|
|||||||
hidden: number;
|
hidden: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageNoticeLevel = 'info' | 'warning';
|
||||||
|
|
||||||
|
export type ImageNotice = {
|
||||||
|
message: string;
|
||||||
|
level: ImageNoticeLevel;
|
||||||
|
code?: string;
|
||||||
|
source?: 'client' | 'server';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageMetadata = {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
size?: number;
|
||||||
|
optimizedSize?: number;
|
||||||
|
colorSpace?: string;
|
||||||
|
quality?: number;
|
||||||
|
resized?: boolean;
|
||||||
|
convertedToRgb?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type UnassignedImage = {
|
export type UnassignedImage = {
|
||||||
file: File;
|
file: File;
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
@@ -33,4 +55,4 @@ export interface Product {
|
|||||||
model?: string;
|
model?: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
product_images?: string | string[];
|
product_images?: string | string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@
|
|||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--info: 217.2 91.2% 59.8%;
|
||||||
|
--info-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--success: 142.1 76.2% 36.3%;
|
||||||
|
--success-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--warning: 45.4 93.4% 47.5%;
|
||||||
|
--warning-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 222.2 84% 4.9%;
|
||||||
@@ -76,6 +85,15 @@
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--info: 217.2 91.2% 59.8%;
|
||||||
|
--info-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--success: 142.1 70.6% 45.3%;
|
||||||
|
--success-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--warning: 45.4 93.4% 47.5%;
|
||||||
|
--warning-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { useState, useContext } from "react";
|
|||||||
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
||||||
import type { StepState } from "@/components/product-import/steps/UploadFlow";
|
import type { StepState } from "@/components/product-import/steps/UploadFlow";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Code } from "@/components/ui/code";
|
import { Code } from "@/components/ui/code";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2, AlertCircle, AlertTriangle, Info, CheckCircle } from "lucide-react";
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||||
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
||||||
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
||||||
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
||||||
@@ -57,6 +58,85 @@ const extractBackendPayload = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractStringFromRecord = (record: Record<string, unknown>): string | null => {
|
||||||
|
const candidateKeys = ["error_msg", "message", "reason", "error", "detail"];
|
||||||
|
|
||||||
|
for (const key of candidateKeys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFailureMessageFromResponse = (response: SubmitNewProductsResponse): string | null => {
|
||||||
|
if (typeof response.message === "string") {
|
||||||
|
const trimmed = response.message.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = response;
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
const trimmed = error.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(error)) {
|
||||||
|
for (const entry of error) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
} else if (isRecord(entry)) {
|
||||||
|
const extracted = extractStringFromRecord(entry);
|
||||||
|
if (extracted) {
|
||||||
|
return extracted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(error)) {
|
||||||
|
return extractStringFromRecord(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractErroredEntriesFromResponse = (
|
||||||
|
response: SubmitNewProductsResponse,
|
||||||
|
): BackendProductResult[] => {
|
||||||
|
const { error } = response;
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(error)) {
|
||||||
|
return error.filter(isRecord) as BackendProductResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(error)) {
|
||||||
|
return [error as BackendProductResult];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
const getFirstStringValue = (value: string | string[] | boolean | null | undefined): string | null => {
|
const getFirstStringValue = (value: string | string[] | boolean | null | undefined): string | null => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
for (const entry of value) {
|
for (const entry of value) {
|
||||||
@@ -172,7 +252,7 @@ export function Import() {
|
|||||||
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
||||||
const [startFromScratch, setStartFromScratch] = useState(false);
|
const [startFromScratch, setStartFromScratch] = useState(false);
|
||||||
const { user } = useContext(AuthContext);
|
const { user } = useContext(AuthContext);
|
||||||
const hasDebugPermission = user?.permissions?.includes("admin:debug") ?? false;
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
||||||
|
|
||||||
// Fetch initial field options from the API
|
// Fetch initial field options from the API
|
||||||
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
||||||
@@ -452,27 +532,60 @@ export function Import() {
|
|||||||
employeeId: user?.id ?? undefined,
|
employeeId: user?.id ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.success) {
|
const isSuccess = response.success;
|
||||||
throw new Error(response.message || "Failed to submit products");
|
const defaultFailureMessage = "Failed to submit products. Please review and try again.";
|
||||||
|
const failureEntries = isSuccess ? [] : extractErroredEntriesFromResponse(response);
|
||||||
|
const resolvedFailureMessage = isSuccess
|
||||||
|
? null
|
||||||
|
: extractFailureMessageFromResponse(response) ?? defaultFailureMessage;
|
||||||
|
|
||||||
|
let normalizedResponse: SubmitNewProductsResponse = response;
|
||||||
|
|
||||||
|
if (!isSuccess) {
|
||||||
|
const baseData: Record<string, unknown> = isRecord(response.data)
|
||||||
|
? { ...(response.data as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (failureEntries.length) {
|
||||||
|
const existingErrored = baseData["errored"];
|
||||||
|
if (!Array.isArray(existingErrored) || existingErrored.length === 0) {
|
||||||
|
baseData["errored"] = failureEntries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDataOverrides = Object.keys(baseData).length > 0;
|
||||||
|
|
||||||
|
normalizedResponse = {
|
||||||
|
...response,
|
||||||
|
success: false,
|
||||||
|
message: resolvedFailureMessage ?? defaultFailureMessage,
|
||||||
|
data: hasDataOverrides ? baseData : response.data,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setResumeStepState(undefined);
|
setResumeStepState(undefined);
|
||||||
setImportOutcome({
|
setImportOutcome({
|
||||||
submittedProducts: formattedRows.map((product) => ({ ...product })),
|
submittedProducts: formattedRows.map((product) => ({ ...product })),
|
||||||
submittedRows: rows.map((row) => ({ ...row })),
|
submittedRows: rows.map((row) => ({ ...row })),
|
||||||
response,
|
response: normalizedResponse,
|
||||||
});
|
});
|
||||||
setIsDebugDataVisible(false);
|
setIsDebugDataVisible(false);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
setStartFromScratch(false);
|
||||||
|
|
||||||
const successMessage = response.message
|
if (isSuccess) {
|
||||||
? response.message
|
const successMessage =
|
||||||
: `Submitted ${formattedRows.length} product${formattedRows.length === 1 ? "" : "s"} successfully`;
|
normalizedResponse.message ||
|
||||||
|
`Submitted ${formattedRows.length} product${formattedRows.length === 1 ? "" : "s"} successfully.`;
|
||||||
toast.success(successMessage);
|
toast.success(successMessage);
|
||||||
|
} else {
|
||||||
|
toast.error(resolvedFailureMessage ?? defaultFailureMessage);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Import error:", error);
|
console.error("Import error:", error);
|
||||||
throw error instanceof Error ? error : new Error("Failed to import data");
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Failed to import data. Please try again.";
|
||||||
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -643,10 +756,7 @@ export function Import() {
|
|||||||
{importOutcome && (
|
{importOutcome && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle>Import Results</CardTitle>
|
<CardTitle>Import Results</CardTitle>
|
||||||
{summaryMessage && <CardDescription>{summaryMessage}</CardDescription>}
|
|
||||||
</div>
|
|
||||||
{hasDebugPermission && (
|
{hasDebugPermission && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -658,15 +768,54 @@ export function Import() {
|
|||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
{importOutcome.response.success === false ? (
|
||||||
Created {createdProducts.length} of {totalSubmitted} product{totalSubmitted === 1 ? "" : "s"}.
|
<Alert variant="destructive">
|
||||||
{erroredProducts.length > 0
|
<AlertCircle className="h-4 w-4" />
|
||||||
? ` ${erroredProducts.length} product${erroredProducts.length === 1 ? "" : "s"} need attention.`
|
<AlertTitle>Error</AlertTitle>
|
||||||
: ""}
|
<AlertDescription>
|
||||||
</p>
|
{summaryMessage ?? "Products not created - please review details and fix."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : createdProducts.length === totalSubmitted && totalSubmitted > 0 ? (
|
||||||
|
<Alert className="border-success bg-success/10">
|
||||||
|
<CheckCircle className="h-4 w-4" style={{ color: 'hsl(var(--success))' }} />
|
||||||
|
<AlertTitle className="text-success">Success</AlertTitle>
|
||||||
|
<AlertDescription className="text-success">All products created successfully.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : createdProducts.length > 0 && erroredProducts.length > 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Partial Success</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{createdProducts.length} product{createdProducts.length === 1 ? "" : "s"} created successfully. {erroredProducts.length} product{erroredProducts.length === 1 ? "" : "s"} need attention.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : erroredProducts.length > 0 ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{summaryMessage ?? "Products not created - please review details and fix."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : summaryMessage ? (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>Notice</AlertTitle>
|
||||||
|
<AlertDescription>{summaryMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{createdProducts.length > 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Created {createdProducts.length} of {totalSubmitted} products.
|
||||||
|
{erroredProducts.length > 0
|
||||||
|
? ` ${erroredProducts.length} product${erroredProducts.length === 1 ? "" : "s"} ${erroredProducts.length === 1 ? "needs" : "need"} attention.`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{erroredProducts.length > 0 && hasErroredRowsForEditing && (
|
{erroredProducts.length > 0 && hasErroredRowsForEditing && (
|
||||||
<Button size="sm" onClick={handleResumeErroredProducts}>
|
<Button size="sm" onClick={handleResumeErroredProducts}>
|
||||||
Fix errored products
|
Fix products with errors
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -714,7 +863,6 @@ export function Import() {
|
|||||||
<span className="text-sm font-medium">{product.name}</span>
|
<span className="text-sm font-medium">{product.name}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{product.pid ? `PID: ${product.pid} · ` : ""}
|
|
||||||
UPC: {product.upc}
|
UPC: {product.upc}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
||||||
@@ -728,7 +876,7 @@ export function Import() {
|
|||||||
|
|
||||||
{erroredProducts.length > 0 && (
|
{erroredProducts.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-lg font-semibold text-destructive">Errored Products</h3>
|
<h3 className="text-lg font-semibold text-destructive">Products with Errors</h3>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{erroredProducts.map((product, index) => (
|
{erroredProducts.map((product, index) => (
|
||||||
<div key={product.upc ?? product.itemNumber ?? index} className="flex items-start gap-4 rounded-md border border-destructive/40 p-4">
|
<div key={product.upc ?? product.itemNumber ?? index} className="flex items-start gap-4 rounded-md border border-destructive/40 p-4">
|
||||||
@@ -757,7 +905,7 @@ export function Import() {
|
|||||||
|
|
||||||
{hasDebugPermission && isDebugDataVisible && (
|
{hasDebugPermission && isDebugDataVisible && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold">Submitted Payload</h3>
|
<h3 className="text-sm font-semibold">Submitted Data</h3>
|
||||||
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
|
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
|
||||||
{JSON.stringify(importOutcome.submittedProducts, null, 2)}
|
{JSON.stringify(importOutcome.submittedProducts, null, 2)}
|
||||||
</Code>
|
</Code>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export async function submitNewProducts({
|
|||||||
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
|
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: SubmitNewProductsResponse | null = null;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(rawBody);
|
parsed = JSON.parse(rawBody);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -68,17 +68,22 @@ export async function submitNewProducts({
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!parsed || typeof parsed !== "object") {
|
||||||
throw new Error(parsed?.message || `Request failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed) {
|
|
||||||
throw new Error("Empty response from backend");
|
throw new Error("Empty response from backend");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.success) {
|
const parsedResponse = parsed as SubmitNewProductsResponse & Record<string, unknown>;
|
||||||
throw new Error(parsed.message || "Backend rejected product submission");
|
const extraFields = parsedResponse as Record<string, unknown>;
|
||||||
|
const normalizedResponse: SubmitNewProductsResponse = {
|
||||||
|
success: Boolean(parsedResponse.success),
|
||||||
|
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
|
||||||
|
data: parsedResponse.data,
|
||||||
|
error: parsedResponse.error ?? extraFields.errors ?? extraFields.error_msg,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok || !normalizedResponse.success) {
|
||||||
|
return normalizedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed;
|
return normalizedResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ export default {
|
|||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: 'hsl(var(--destructive-foreground))'
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
},
|
},
|
||||||
|
info: {
|
||||||
|
DEFAULT: 'hsl(var(--info))',
|
||||||
|
foreground: 'hsl(var(--info-foreground))'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: 'hsl(var(--success))',
|
||||||
|
foreground: 'hsl(var(--success-foreground))'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: 'hsl(var(--warning))',
|
||||||
|
foreground: 'hsl(var(--warning-foreground))'
|
||||||
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: 'hsl(var(--muted-foreground))'
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user