feat: update membership gate copy for clarity and engagement
feat: add file_url column to factory_reports and update related queries chore: upgrade nodemailer to version 9.0.1 and undici to version 7.28.0 feat: enhance factory report editor with file upload functionality for PDFs fix: increase bcrypt hash rounds for improved password security fix: restrict file uploads to video, audio, and PDF formats chore: implement safe URL handling for banner CTA links chore: seed radar_items with random dates, sources, and tags for consistency add: new image asset for chart analysis
This commit is contained in:
parent
5a3d405ff4
commit
245097c0f2
|
|
@ -45,13 +45,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"js-tokens": "^4.0.0",
|
"js-tokens": "^4.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
|
|
@ -60,9 +60,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.29.3",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -70,21 +70,21 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-compilation-targets": "^7.28.6",
|
"@babel/helper-compilation-targets": "^7.29.7",
|
||||||
"@babel/helper-module-transforms": "^7.28.6",
|
"@babel/helper-module-transforms": "^7.29.7",
|
||||||
"@babel/helpers": "^7.28.6",
|
"@babel/helpers": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
|
|
@ -101,14 +101,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
|
|
@ -118,14 +118,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets": {
|
"node_modules/@babel/helper-compilation-targets": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.28.6",
|
"@babel/compat-data": "^7.29.7",
|
||||||
"@babel/helper-validator-option": "^7.27.1",
|
"@babel/helper-validator-option": "^7.29.7",
|
||||||
"browserslist": "^4.24.0",
|
"browserslist": "^4.24.0",
|
||||||
"lru-cache": "^5.1.1",
|
"lru-cache": "^5.1.1",
|
||||||
"semver": "^6.3.1"
|
"semver": "^6.3.1"
|
||||||
|
|
@ -135,9 +135,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-globals": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.28.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -145,29 +145,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-imports": {
|
"node_modules/@babel/helper-module-imports": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.28.6",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.28.6",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"@babel/traverse": "^7.28.6"
|
"@babel/traverse": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -177,9 +177,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -187,9 +187,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -197,9 +197,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-option": {
|
"node_modules/@babel/helper-validator-option": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -207,27 +207,27 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.3",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
|
|
@ -246,33 +246,33 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -280,14 +280,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -300,21 +300,21 @@
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -323,9 +323,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -596,14 +596,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tybys/wasm-util": "^0.10.1"
|
"@tybys/wasm-util": "^0.10.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -615,9 +615,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.132.0",
|
"version": "0.137.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
|
||||||
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
|
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|
@ -746,9 +746,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
|
||||||
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
|
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -763,9 +763,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
|
||||||
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
|
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -780,9 +780,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
|
||||||
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
|
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -797,9 +797,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
|
||||||
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
|
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -814,9 +814,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
|
||||||
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
|
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
|
@ -831,9 +831,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
|
||||||
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
|
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -848,9 +848,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
|
||||||
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
|
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -865,9 +865,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
|
||||||
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
|
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
|
@ -882,9 +882,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
|
||||||
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
|
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
|
@ -899,9 +899,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
|
||||||
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
|
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -916,9 +916,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
|
||||||
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
|
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -933,9 +933,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
|
||||||
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
|
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -950,9 +950,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
|
||||||
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
|
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
|
|
@ -960,18 +960,18 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/core": "1.10.0",
|
"@emnapi/core": "1.11.1",
|
||||||
"@emnapi/runtime": "1.10.0",
|
"@emnapi/runtime": "1.11.1",
|
||||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
|
||||||
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
|
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -986,9 +986,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
|
||||||
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
|
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1331,9 +1331,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -1817,9 +1817,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.32",
|
"version": "2.10.40",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||||
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
|
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -1852,9 +1852,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -1872,10 +1872,10 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.38",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001799",
|
||||||
"electron-to-chromium": "^1.5.328",
|
"electron-to-chromium": "^1.5.376",
|
||||||
"node-releases": "^2.0.36",
|
"node-releases": "^2.0.48",
|
||||||
"update-browserslist-db": "^1.2.3"
|
"update-browserslist-db": "^1.2.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -1923,9 +1923,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001793",
|
"version": "1.0.30001799",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||||
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
|
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -2262,9 +2262,9 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.361",
|
"version": "1.5.381",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
|
||||||
"integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==",
|
"integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
|
@ -3427,9 +3427,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.46",
|
"version": "2.0.50",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
||||||
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
|
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -3782,13 +3782,13 @@
|
||||||
"license": "Unlicense"
|
"license": "Unlicense"
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
|
||||||
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
|
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.132.0",
|
"@oxc-project/types": "=0.137.0",
|
||||||
"@rolldown/pluginutils": "^1.0.0"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -3798,21 +3798,21 @@
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.2",
|
"@rolldown/binding-android-arm64": "1.1.3",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.2",
|
"@rolldown/binding-darwin-arm64": "1.1.3",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.2",
|
"@rolldown/binding-darwin-x64": "1.1.3",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.2",
|
"@rolldown/binding-freebsd-x64": "1.1.3",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
|
"@rolldown/binding-linux-arm64-gnu": "1.1.3",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.2",
|
"@rolldown/binding-linux-arm64-musl": "1.1.3",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
|
"@rolldown/binding-linux-ppc64-gnu": "1.1.3",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
|
"@rolldown/binding-linux-s390x-gnu": "1.1.3",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.2",
|
"@rolldown/binding-linux-x64-gnu": "1.1.3",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.2",
|
"@rolldown/binding-linux-x64-musl": "1.1.3",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.2",
|
"@rolldown/binding-openharmony-arm64": "1.1.3",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.2",
|
"@rolldown/binding-wasm32-wasi": "1.1.3",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
|
"@rolldown/binding-win32-arm64-msvc": "1.1.3",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.2"
|
"@rolldown/binding-win32-x64-msvc": "1.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
|
|
@ -4090,9 +4090,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -4310,17 +4310,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.14",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
|
||||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"rolldown": "1.0.2",
|
"rolldown": "~1.1.2",
|
||||||
"tinyglobby": "^0.2.16"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
|
|
@ -4336,7 +4336,7 @@
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.1.18",
|
"@vitejs/devtools": "^0.3.0",
|
||||||
"esbuild": "^0.27.0 || ^0.28.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
import { NavLink, Link, useNavigate } from 'react-router-dom'
|
import { NavLink, Link, useNavigate } from 'react-router-dom'
|
||||||
import { Search, Menu, X, ChevronDown, Radar, Leaf, Globe, Cpu, BarChart3, Video, Mic, CalendarDays, Users, Eye, Phone, type LucideIcon } from 'lucide-react'
|
import { Search, Menu, X, ChevronDown, Radar, Leaf, Globe, Cpu, BarChart3, Video, CalendarDays, Users, Eye, Phone, type LucideIcon } from 'lucide-react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
|
@ -31,9 +31,9 @@ const SECTIONS: Section[] = [
|
||||||
|
|
||||||
/* ─── dropdown sections under رسانه و رویداد ─── */
|
/* ─── dropdown sections under رسانه و رویداد ─── */
|
||||||
const MEDIA: Section[] = [
|
const MEDIA: Section[] = [
|
||||||
{ fa: 'ویدیوکست و پادکست', en: 'Videocast & Podcast', to: '/podcast', Icon: Video },
|
{ fa: 'ویدیوکست و پادکست', en: 'Videocast & Podcast', to: '/podcast', Icon: Video },
|
||||||
{ fa: 'نشستهای تخصصی', en: 'Expert Sessions', to: '/specialized-meetings', Icon: Mic },
|
{ fa: 'رویدادهای صنعت فولاد', en: 'Steel Industry Events', to: '/events', Icon: CalendarDays },
|
||||||
{ fa: 'رویدادها', en: 'Events', to: '/events', Icon: CalendarDays },
|
{ fa: 'تقویم اندیشکده', en: 'Institute Calendar', to: '/events#calendar', Icon: CalendarDays },
|
||||||
]
|
]
|
||||||
|
|
||||||
/* ─── dropdown sections under درباره ما ─── */
|
/* ─── dropdown sections under درباره ما ─── */
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ function toMember(u: Partial<Member> & { id: number; name: string; email: string
|
||||||
function load(): AuthState {
|
function load(): AuthState {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('member_info')
|
const raw = localStorage.getItem('member_info')
|
||||||
if (raw) return { member: JSON.parse(raw) }
|
// whitelist fields via toMember — a poisoned cache must not inject extra props into state
|
||||||
|
if (raw) return { member: toMember(JSON.parse(raw)) }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return { member: null }
|
return { member: null }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ export const banners: Banner[] = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Only allow http(s) URLs to reach window.open/href; null out javascript:/data:/etc. */
|
||||||
|
const safeUrl = (u: unknown): string =>
|
||||||
|
typeof u === 'string' && /^https?:\/\//i.test(u) ? u : ''
|
||||||
|
|
||||||
/** First active banner for a given position (used by section components). */
|
/** First active banner for a given position (used by section components). */
|
||||||
export function bannerFor(position: string): Banner | undefined {
|
export function bannerFor(position: string): Banner | undefined {
|
||||||
return banners.find((b) => b.active && b.position === position)
|
return banners.find((b) => b.active && b.position === position)
|
||||||
|
|
@ -61,7 +65,7 @@ export async function bootstrapBanners(): Promise<void> {
|
||||||
position: String(b.position ?? 'featured'),
|
position: String(b.position ?? 'featured'),
|
||||||
active: Boolean(b.active),
|
active: Boolean(b.active),
|
||||||
image: String(b.image ?? ''),
|
image: String(b.image ?? ''),
|
||||||
ctaUrl: String(b.ctaUrl ?? ''),
|
ctaUrl: safeUrl(b.ctaUrl),
|
||||||
fa: {
|
fa: {
|
||||||
overline: String(b.overlineFa ?? ''),
|
overline: String(b.overlineFa ?? ''),
|
||||||
title: String(b.titleFa ?? ''),
|
title: String(b.titleFa ?? ''),
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ function MemberCard({ m, selected, onClick }: { m: { photo: string; name: string
|
||||||
}}>
|
}}>
|
||||||
{/* framed photo */}
|
{/* framed photo */}
|
||||||
<div style={{ borderRadius: 13, overflow: 'hidden' }}>
|
<div style={{ borderRadius: 13, overflow: 'hidden' }}>
|
||||||
<img src={m.photo} alt={m.name} style={{ width: '100%', height: 236, objectFit: 'cover', objectPosition: 'top center', display: 'block' }} />
|
<img src={m.photo} alt={m.name} style={{ width: '100%', height: 236, objectFit: 'cover', objectPosition: 'top center', display: 'block', filter: 'blur(8px)' }} />
|
||||||
</div>
|
</div>
|
||||||
{/* gold accent line under the photo */}
|
{/* gold accent line under the photo */}
|
||||||
<div style={{ height: 3, margin: '12px 16px 0', borderRadius: 99, background: `linear-gradient(90deg, rgba(205,158,83,0) 0%, ${GOLD} 16%, ${GOLD} 84%, rgba(205,158,83,0) 100%)` }} />
|
<div style={{ height: 3, margin: '12px 16px 0', borderRadius: 99, background: `linear-gradient(90deg, rgba(205,158,83,0) 0%, ${GOLD} 16%, ${GOLD} 84%, rgba(205,158,83,0) 100%)` }} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, type CSSProperties } from 'react'
|
import { useState, useEffect, type CSSProperties } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { X, Globe, Calendar, LayoutGrid, ExternalLink, Newspaper, LineChart, ChevronDown } from 'lucide-react'
|
import { X, Globe, Calendar, LayoutGrid, ExternalLink, Newspaper, ChevronDown } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
|
|
@ -219,25 +219,33 @@ const CARDS: Card[] = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const NEWS_ITEMS = [
|
const NEWS_ITEMS = [
|
||||||
{ titleFa: 'فولاد مبارکه رکورد تولید ماهانه را شکست', titleEn: 'Mobarakeh Steel breaks monthly production record', dateFa: '۱۴ اردیبهشت', dateEn: 'May 4' },
|
{ titleFa: 'فولاد مبارکه رکورد تولید ماهانه را شکست', titleEn: 'Mobarakeh Steel breaks monthly production record', dateFa: '۱۴ اردیبهشت ۱۴۰۵', dateEn: 'May 4, 2026' },
|
||||||
{ titleFa: 'تفاهمنامه همکاری ایران و روسیه در حوزه فولاد', titleEn: 'Iran-Russia steel cooperation MOU signed', dateFa: '۱۲ اردیبهشت', dateEn: 'May 2' },
|
{ titleFa: 'تفاهمنامه همکاری ایران و روسیه در حوزه فولاد', titleEn: 'Iran-Russia steel cooperation MOU signed', dateFa: '۱۲ اردیبهشت ۱۴۰۵', dateEn: 'May 2, 2026' },
|
||||||
{ titleFa: 'افزایش ۸ درصدی صادرات فولاد در فروردین', titleEn: '8% rise in steel exports in April', dateFa: '۱۰ اردیبهشت', dateEn: 'Apr 30' },
|
{ titleFa: 'افزایش ۸ درصدی صادرات فولاد در فروردین', titleEn: '8% rise in steel exports in April', dateFa: '۱۰ اردیبهشت ۱۴۰۵', dateEn: 'Apr 30, 2026' },
|
||||||
{ titleFa: 'نشست وزرای صنعت کشورهای عضو ECO', titleEn: 'ECO member states industry ministers summit', dateFa: '۸ اردیبهشت', dateEn: 'Apr 28' },
|
{ titleFa: 'نشست وزرای صنعت کشورهای عضو ECO', titleEn: 'ECO member states industry ministers summit', dateFa: '۸ اردیبهشت ۱۴۰۵', dateEn: 'Apr 28, 2026' },
|
||||||
{ titleFa: 'راهاندازی خط تولید ورق گالوانیزه در اصفهان', titleEn: 'Galvanized sheet production line launched in Isfahan', dateFa: '۶ اردیبهشت', dateEn: 'Apr 26' },
|
{ titleFa: 'راهاندازی خط تولید ورق گالوانیزه در اصفهان', titleEn: 'Galvanized sheet production line launched in Isfahan', dateFa: '۶ اردیبهشت ۱۴۰۵', dateEn: 'Apr 26, 2026' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
/* ─── modal — pixel-matched to Figma node 6791-15495 ─── */
|
/* ─── modal — pixel-matched to Figma node 6791-15495 ─── */
|
||||||
const SectionHead = ({ Icon, children }: { Icon: typeof Newspaper; children: string }) => (
|
const SectionHead = ({ Icon, img, children }: { Icon?: typeof Newspaper; img?: string; children: string }) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
<Icon size={22} color="#0084E6" strokeWidth={1.6} />
|
{img
|
||||||
|
? <img src={img} alt="" width={22} height={22} style={{ objectFit: 'contain' }} />
|
||||||
|
: Icon ? <Icon size={22} color="#0084E6" strokeWidth={1.6} /> : null}
|
||||||
<span style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: '28px' }}>{children}</span>
|
<span style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: '28px' }}>{children}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onClose: () => void }) {
|
function CardModal({ card, lang, onClose, onTagClick }: { card: Card; lang: 'fa' | 'en'; onClose: () => void; onTagClick: (tag: string) => void }) {
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
const dir = isFa ? 'rtl' : 'ltr'
|
const dir = isFa ? 'rtl' : 'ltr'
|
||||||
|
// lock background scroll so the wheel scrolls the modal body, not the page behind it
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => { document.body.style.overflow = prev }
|
||||||
|
}, [])
|
||||||
const body = isFa ? card.bodyFa : card.bodyEn
|
const body = isFa ? card.bodyFa : card.bodyEn
|
||||||
const bodyText: CSSProperties = { fontSize: 14, lineHeight: '24px', color: '#676F71', textAlign: 'justify', margin: 0 }
|
const bodyText: CSSProperties = { fontSize: 14, lineHeight: '24px', color: '#676F71', textAlign: 'justify', margin: 0 }
|
||||||
const metaItem: CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#868686', lineHeight: '21px' }
|
const metaItem: CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#868686', lineHeight: '21px' }
|
||||||
|
|
@ -260,9 +268,6 @@ function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onC
|
||||||
{/* cover */}
|
{/* cover */}
|
||||||
<div style={{ position: 'relative', height: 284, minHeight: 284 }}>
|
<div style={{ position: 'relative', height: 284, minHeight: 284 }}>
|
||||||
<img src={card.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<img src={card.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
<button onClick={onClose} style={{ position: 'absolute', top: 14, ...(isFa ? { left: 14 } : { right: 14 }), width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'rgba(3,35,64,0.55)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* content */}
|
{/* content */}
|
||||||
|
|
@ -288,7 +293,7 @@ function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onC
|
||||||
|
|
||||||
{/* اصل رویداد */}
|
{/* اصل رویداد */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<SectionHead Icon={Newspaper}>{isFa ? 'اصل رویداد' : 'The Event'}</SectionHead>
|
<SectionHead Icon={Newspaper}>{isFa ? 'رویداد' : 'The Event'}</SectionHead>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{body.map((p, i) => <p key={i} style={bodyText}>{p}</p>)}
|
{body.map((p, i) => <p key={i} style={bodyText}>{p}</p>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -296,14 +301,18 @@ function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onC
|
||||||
|
|
||||||
{/* دلالتهای راهبردی */}
|
{/* دلالتهای راهبردی */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<SectionHead Icon={LineChart}>{isFa ? 'دلالتهای راهبردی' : 'Strategic Implications'}</SectionHead>
|
<SectionHead img="/hugeicons_chart-analysis.png">{isFa ? 'دلالتهای راهبردی' : 'Strategic Implications'}</SectionHead>
|
||||||
<p style={bodyText}>{isFa ? card.analysisFa : card.analysisEn}</p>
|
<p style={bodyText}>{isFa ? card.analysisFa : card.analysisEn}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* tags */}
|
{/* tags */}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 20 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 20 }}>
|
||||||
{card.tags.map(t => (
|
{card.tags.map(t => (
|
||||||
<span key={t} style={{ background: '#EDEDED', color: '#262626', fontSize: 12, lineHeight: '21px', padding: '4px 8px', borderRadius: 16 }}>#{''}{t.replace(/ /g, '_')}</span>
|
<button key={t} type="button" onClick={() => onTagClick(t)}
|
||||||
|
style={{ background: '#EDEDED', color: '#262626', fontSize: 12, lineHeight: '21px', padding: '4px 10px', borderRadius: 16, border: 'none', cursor: 'pointer', fontFamily: 'inherit', transition: 'background 160ms, color 160ms' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = GOLD; e.currentTarget.style.color = '#fff' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = '#EDEDED'; e.currentTarget.style.color = '#262626' }}
|
||||||
|
>#{t.replace(/ /g, '_')}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -341,6 +350,23 @@ function Badge({ label }: { label: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── clickable hashtags shown directly on cards (filters the list on click) ─── */
|
||||||
|
function TagRow({ tags, onTagClick }: { tags: string[]; onTagClick: (t: string) => void }) {
|
||||||
|
if (!tags?.length) return null
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||||
|
{tags.slice(0, 4).map(t => (
|
||||||
|
<button key={t} type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onTagClick(t) }}
|
||||||
|
style={{ background: '#EDEDED', color: '#262626', fontSize: 11, lineHeight: '20px', padding: '2px 9px', borderRadius: 14, border: 'none', cursor: 'pointer', fontFamily: 'inherit', transition: 'background 160ms, color 160ms' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = GOLD; e.currentTarget.style.color = '#fff' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = '#EDEDED'; e.currentTarget.style.color = '#262626' }}
|
||||||
|
>#{t.replace(/ /g, '_')}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── main page ─── */
|
/* ─── main page ─── */
|
||||||
export default function AtAGlance() {
|
export default function AtAGlance() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
|
|
@ -348,8 +374,12 @@ export default function AtAGlance() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab | 'all'>('all')
|
const [activeTab, setActiveTab] = useState<Tab | 'all'>('all')
|
||||||
const [selectedCard, setSelectedCard] = useState<Card | null>(null)
|
const [selectedCard, setSelectedCard] = useState<Card | null>(null)
|
||||||
const [showAllList, setShowAllList] = useState(false)
|
const [showAllList, setShowAllList] = useState(false)
|
||||||
|
const [tagFilter, setTagFilter] = useState<string | null>(null)
|
||||||
const [cards, setCards] = useState<Card[]>(CARDS)
|
const [cards, setCards] = useState<Card[]>(CARDS)
|
||||||
|
|
||||||
|
// clicking a hashtag in the modal filters the list to every post carrying that tag
|
||||||
|
const handleTagClick = (tag: string) => { setTagFilter(tag); setActiveTab('all'); setSelectedCard(null); setShowAllList(true) }
|
||||||
|
|
||||||
// load «در یک نگاه» cards from the panel (radar_items); fall back to the static list
|
// load «در یک نگاه» cards from the panel (radar_items); fall back to the static list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
||||||
|
|
@ -376,7 +406,9 @@ export default function AtAGlance() {
|
||||||
return () => { alive = false }
|
return () => { alive = false }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filteredCards = activeTab === 'all' ? cards : cards.filter(c => c.tab === activeTab)
|
const filteredCards = tagFilter
|
||||||
|
? cards.filter(c => c.tags.includes(tagFilter))
|
||||||
|
: activeTab === 'all' ? cards : cards.filter(c => c.tab === activeTab)
|
||||||
const hero = filteredCards[0]
|
const hero = filteredCards[0]
|
||||||
const smallItems = filteredCards.slice(1, 4)
|
const smallItems = filteredCards.slice(1, 4)
|
||||||
const listCards = filteredCards.slice(1)
|
const listCards = filteredCards.slice(1)
|
||||||
|
|
@ -452,6 +484,7 @@ export default function AtAGlance() {
|
||||||
<div><Badge label={catLabel(card.tab, isFa)} /></div>
|
<div><Badge label={catLabel(card.tab, isFa)} /></div>
|
||||||
<Meta source={card.source} date={isFa ? card.dateFa : card.dateEn} />
|
<Meta source={card.source} date={isFa ? card.dateFa : card.dateEn} />
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#262626', lineHeight: 1.7, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{isFa ? card.titleFa : card.titleEn}</div>
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#262626', lineHeight: 1.7, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{isFa ? card.titleFa : card.titleEn}</div>
|
||||||
|
<TagRow tags={card.tags} onTagClick={handleTagClick} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -468,6 +501,16 @@ export default function AtAGlance() {
|
||||||
<span style={{ width: 3, height: 26, background: GOLD, borderRadius: 2 }} />
|
<span style={{ width: 3, height: 26, background: GOLD, borderRadius: 2 }} />
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'آخرین تحولات' : 'Latest Developments'}</h2>
|
<h2 style={{ fontSize: 20, fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'آخرین تحولات' : 'Latest Developments'}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{tagFilter && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 13, color: '#606060' }}>{isFa ? 'نتایج برای' : 'Results for'}</span>
|
||||||
|
<button type="button" onClick={() => setTagFilter(null)}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: GOLD, color: '#fff', border: 'none', borderRadius: 16, padding: '4px 12px', fontSize: 12.5, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
#{tagFilter.replace(/ /g, '_')}
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{visibleList.map(card => (
|
{visibleList.map(card => (
|
||||||
|
|
@ -480,6 +523,7 @@ export default function AtAGlance() {
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: 1.8, margin: '0 0 8px' }}>{isFa ? card.titleFa : card.titleEn}</h3>
|
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: 1.8, margin: '0 0 8px' }}>{isFa ? card.titleFa : card.titleEn}</h3>
|
||||||
<p style={{ fontSize: 13, color: '#606060', lineHeight: 1.9, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? card.bodyFa : card.bodyEn)[0] || ''}</p>
|
<p style={{ fontSize: 13, color: '#606060', lineHeight: 1.9, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? card.bodyFa : card.bodyEn)[0] || ''}</p>
|
||||||
|
<TagRow tags={card.tags} onTagClick={handleTagClick} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -505,9 +549,9 @@ export default function AtAGlance() {
|
||||||
<aside style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
<aside style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
<div style={{ background: '#fff', borderRadius: 14, padding: '20px 22px', border: '1px solid rgba(3,35,64,0.07)' }}>
|
<div style={{ background: '#fff', borderRadius: 14, padding: '20px 22px', border: '1px solid rgba(3,35,64,0.07)' }}>
|
||||||
<h4 style={sidebarH}>{isFa ? 'دستهبندیها' : 'Categories'}</h4>
|
<h4 style={sidebarH}>{isFa ? 'دستهبندیها' : 'Categories'}</h4>
|
||||||
<button onClick={() => setActiveTab('all')} style={catRow(activeTab === 'all')}><span>{isFa ? 'همه' : 'All'}</span><span style={catNum}>{cards.length}</span></button>
|
<button onClick={() => { setActiveTab('all'); setTagFilter(null) }} style={catRow(activeTab === 'all' && !tagFilter)}><span>{isFa ? 'همه' : 'All'}</span><span style={catNum}>{cards.length}</span></button>
|
||||||
{catCounts.map(c => (
|
{catCounts.map(c => (
|
||||||
<button key={c.id} onClick={() => setActiveTab(c.id)} style={catRow(activeTab === c.id)}><span>{isFa ? c.fa : c.en}</span><span style={catNum}>{c.n}</span></button>
|
<button key={c.id} onClick={() => { setActiveTab(c.id); setTagFilter(null) }} style={catRow(activeTab === c.id && !tagFilter)}><span>{isFa ? c.fa : c.en}</span><span style={catNum}>{c.n}</span></button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{archive.length > 0 && (
|
{archive.length > 0 && (
|
||||||
|
|
@ -551,7 +595,7 @@ export default function AtAGlance() {
|
||||||
{/* modal */}
|
{/* modal */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{selectedCard && (
|
{selectedCard && (
|
||||||
<CardModal card={selectedCard} lang={lang} onClose={() => setSelectedCard(null)} />
|
<CardModal card={selectedCard} lang={lang} onClose={() => setSelectedCard(null)} onTagClick={handleTagClick} />
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
import { ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { events } from '@/data/events'
|
import { events } from '@/data/events'
|
||||||
|
|
@ -173,6 +174,16 @@ export default function Events() {
|
||||||
const n = new Date(calAnchor); n.setDate(n.getDate() + dir * 30); setCalAnchor(n)
|
const n = new Date(calAnchor); n.setDate(n.getDate() + dir * 30); setCalAnchor(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scroll to the calendar when arriving via the «تقویم اندیشکده» nav link (/events#calendar)
|
||||||
|
const { hash } = useLocation()
|
||||||
|
useEffect(() => {
|
||||||
|
if (hash !== '#calendar') return
|
||||||
|
const el = document.getElementById('calendar')
|
||||||
|
if (!el) return
|
||||||
|
const timer = setTimeout(() => window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 90, behavior: 'smooth' }), 120)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [hash])
|
||||||
|
|
||||||
// approximate Gregorian marker dates for each event
|
// approximate Gregorian marker dates for each event
|
||||||
const MONTH_OFF: Record<string, number> = {
|
const MONTH_OFF: Record<string, number> = {
|
||||||
فروردین:3, اردیبهشت:4, خرداد:5, تیر:6, مرداد:7, شهریور:8,
|
فروردین:3, اردیبهشت:4, خرداد:5, تیر:6, مرداد:7, شهریور:8,
|
||||||
|
|
@ -244,7 +255,7 @@ export default function Events() {
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '409px 1fr', gap: 37, direction: 'ltr', alignItems: 'stretch' }} className="max-lg:!grid-cols-1">
|
<div style={{ display: 'grid', gridTemplateColumns: '409px 1fr', gap: 37, direction: 'ltr', alignItems: 'stretch' }} className="max-lg:!grid-cols-1">
|
||||||
|
|
||||||
{/* LEFT — calendar */}
|
{/* LEFT — calendar */}
|
||||||
<div dir={isFa ? 'rtl' : 'ltr'}>
|
<div id="calendar" dir={isFa ? 'rtl' : 'ltr'} style={{ scrollMarginTop: 90 }}>
|
||||||
<MiniCalendar anchor={calAnchor} onNav={handleCalNav} eventDates={eventDates}
|
<MiniCalendar anchor={calAnchor} onNav={handleCalNav} eventDates={eventDates}
|
||||||
onSelect={d => {
|
onSelect={d => {
|
||||||
const jp = jParts(d)
|
const jp = jParts(d)
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ export default function Experts() {
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.98 }}
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'blur(10px)' }}
|
||||||
/>
|
/>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -465,9 +465,9 @@ export default function Experts() {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
filter: (isActive || isHovered) ? 'none' : 'grayscale(100%)',
|
filter: 'blur(5px)',
|
||||||
transition: 'filter 0.3s ease'
|
transition: 'filter 0.3s ease'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
@ -567,7 +567,7 @@ function ExpertCard({ name, role, photo, icon }: { name: string; role: string; p
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
filter: hovered ? 'grayscale(0%)' : 'grayscale(100%)',
|
filter: 'blur(8px)',
|
||||||
transform: hovered ? 'scale(1.03)' : 'scale(1)',
|
transform: hovered ? 'scale(1.03)' : 'scale(1)',
|
||||||
transition: 'all 0.4s ease'
|
transition: 'all 0.4s ease'
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ type Glance = {
|
||||||
const ITEMS: Glance[] = [
|
const ITEMS: Glance[] = [
|
||||||
{
|
{
|
||||||
source: 'Financial Times',
|
source: 'Financial Times',
|
||||||
date: 'اردیبهشت ۱۴۰۵', dateEn: 'May 2026',
|
date: '۱۴ اردیبهشت ۱۴۰۵', dateEn: 'May 4, 2026',
|
||||||
title: 'شوک هیدروژنی به بازار جهانی؛ آغاز رسمی عصر فولادسازی بدون کربن',
|
title: 'شوک هیدروژنی به بازار جهانی؛ آغاز رسمی عصر فولادسازی بدون کربن',
|
||||||
titleEn: 'Hydrogen Shock to Global Markets; the Carbon-Free Steel Era Begins',
|
titleEn: 'Hydrogen Shock to Global Markets; the Carbon-Free Steel Era Begins',
|
||||||
desc: 'پروژه عظیم «فولاد سبز H2» در سوئد با جذب بیسابقه ۶.۵ میلیارد یورو سرمایه، رسماً وارد فاز اجرایی شد تا نخستین کارخانه فولادسازی در مقیاس تجاری بر پایه هیدروژن سبز (بدون نیاز به زغالسنگ) در جهان متولد شود. این تأسیسات با هدف کاهش ۹۵ درصدی انتشار CO₂، قواعد رقابت در زنجیره ارزش اروپا را برای همیشه بازتعریف میکند.',
|
desc: 'پروژه عظیم «فولاد سبز H2» در سوئد با جذب بیسابقه ۶.۵ میلیارد یورو سرمایه، رسماً وارد فاز اجرایی شد تا نخستین کارخانه فولادسازی در مقیاس تجاری بر پایه هیدروژن سبز (بدون نیاز به زغالسنگ) در جهان متولد شود. این تأسیسات با هدف کاهش ۹۵ درصدی انتشار CO₂، قواعد رقابت در زنجیره ارزش اروپا را برای همیشه بازتعریف میکند.',
|
||||||
|
|
@ -26,7 +26,7 @@ const ITEMS: Glance[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'Reuters',
|
source: 'Reuters',
|
||||||
date: 'اردیبهشت ۱۴۰۵', dateEn: 'May 2026',
|
date: '۱۲ اردیبهشت ۱۴۰۵', dateEn: 'May 2, 2026',
|
||||||
title: 'مکانیسم تنظیم مرزهای کربنی اروپا وارد فاز اجرای کامل شد',
|
title: 'مکانیسم تنظیم مرزهای کربنی اروپا وارد فاز اجرای کامل شد',
|
||||||
titleEn: 'EU Carbon Border Mechanism Enters Full Enforcement',
|
titleEn: 'EU Carbon Border Mechanism Enters Full Enforcement',
|
||||||
desc: 'با اجرای کامل CBAM، صادرکنندگان فولاد به اتحادیه اروپا اکنون موظفاند هزینه کامل کربن نهفته در محصولات خود را بپردازند. این تحول، فشار بیسابقهای بر تولیدکنندگان مبتنی بر کوره بلند وارد کرده و مزیت رقابتی را به سمت فولاد کمکربن سوق میدهد.',
|
desc: 'با اجرای کامل CBAM، صادرکنندگان فولاد به اتحادیه اروپا اکنون موظفاند هزینه کامل کربن نهفته در محصولات خود را بپردازند. این تحول، فشار بیسابقهای بر تولیدکنندگان مبتنی بر کوره بلند وارد کرده و مزیت رقابتی را به سمت فولاد کمکربن سوق میدهد.',
|
||||||
|
|
@ -35,7 +35,7 @@ const ITEMS: Glance[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'Bloomberg',
|
source: 'Bloomberg',
|
||||||
date: 'فروردین ۱۴۰۵', dateEn: 'Apr 2026',
|
date: '۳۰ فروردین ۱۴۰۵', dateEn: 'Apr 19, 2026',
|
||||||
title: 'هوش مصنوعی، تقاضای جهانی فولاد مراکز داده را بازتعریف میکند',
|
title: 'هوش مصنوعی، تقاضای جهانی فولاد مراکز داده را بازتعریف میکند',
|
||||||
titleEn: 'AI Is Rewriting Global Steel Demand for Data Centers',
|
titleEn: 'AI Is Rewriting Global Steel Demand for Data Centers',
|
||||||
desc: 'موج ساخت مراکز داده هوش مصنوعی، تقاضای جدیدی برای مقاطع ساختمانی و فولاد ویژه ایجاد کرده است. تحلیلگران پیشبینی میکنند این روند تا پایان دهه، یکی از پیشرانهای اصلی رشد مصرف فولاد در اقتصادهای پیشرفته باشد.',
|
desc: 'موج ساخت مراکز داده هوش مصنوعی، تقاضای جدیدی برای مقاطع ساختمانی و فولاد ویژه ایجاد کرده است. تحلیلگران پیشبینی میکنند این روند تا پایان دهه، یکی از پیشرانهای اصلی رشد مصرف فولاد در اقتصادهای پیشرفته باشد.',
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { FileText, ArrowLeft } from 'lucide-react'
|
import { FileText } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
|
||||||
const MSC_URL = 'https://www.msc.ir/fa-IR/Portal/8029/page/%D9%85%D8%A7%D9%87%D9%86%D8%A7%D9%85%D9%87-%D8%AA%D8%AD%D9%84%DB%8C%D9%84%DB%8C-%D8%B1%D8%A7%D9%87%D8%A8%D8%B1%D8%AF%DB%8C-%DA%A9%D8%A7%D8%B1%D8%AE%D8%A7%D9%86%D9%87'
|
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
const EASE = [0.22, 1, 0.36, 1] as const
|
const EASE = [0.22, 1, 0.36, 1] as const
|
||||||
|
|
||||||
type ScrollDir = 'left' | 'right' | 'up'
|
type ScrollDir = 'left' | 'right' | 'up'
|
||||||
|
type Cover = { img: string; num: string; dir: ScrollDir; fileUrl?: string }
|
||||||
|
|
||||||
/* 5 monthly-magazine covers exported from the Figma design */
|
/* static fallback — Figma covers; replaced at runtime by panel factory-reports */
|
||||||
const COVERS: { img: string; num: string; dir: ScrollDir }[] = [
|
const COVERS: Cover[] = [
|
||||||
{ img: '/factory/cover-1.png', num: '۵۵', dir: 'right' },
|
{ img: '/factory/cover-1.png', num: '۵۵', dir: 'right' },
|
||||||
{ img: '/factory/cover-2.png', num: '۵۴', dir: 'up' },
|
{ img: '/factory/cover-2.png', num: '۵۴', dir: 'up' },
|
||||||
{ img: '/factory/cover-3.png', num: '۵۳', dir: 'left' },
|
{ img: '/factory/cover-3.png', num: '۵۳', dir: 'left' },
|
||||||
|
|
@ -19,6 +19,11 @@ const COVERS: { img: string; num: string; dir: ScrollDir }[] = [
|
||||||
{ img: '/factory/cover-5.png', num: '۵۱', dir: 'left' },
|
{ img: '/factory/cover-5.png', num: '۵۱', dir: 'left' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const FALLBACK_DIRS: ScrollDir[] = ['right', 'up', 'left', 'up', 'left']
|
||||||
|
// only allow http(s) or root-relative paths to reach a download href
|
||||||
|
const safeFile = (u: unknown): string | undefined =>
|
||||||
|
typeof u === 'string' && (/^https?:\/\//i.test(u) || u.startsWith('/')) ? u : undefined
|
||||||
|
|
||||||
function ScrollAnimation({ children, direction = 'up' }: { children: React.ReactNode; direction?: ScrollDir }) {
|
function ScrollAnimation({ children, direction = 'up' }: { children: React.ReactNode; direction?: ScrollDir }) {
|
||||||
const offset =
|
const offset =
|
||||||
direction === 'left' ? { x: -50, y: 0 } :
|
direction === 'left' ? { x: -50, y: 0 } :
|
||||||
|
|
@ -37,12 +42,19 @@ function ScrollAnimation({ children, direction = 'up' }: { children: React.React
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* compact card — cover + navy download bar + issue-number badge */
|
/* compact card — cover + navy download bar (direct file download) + issue-number badge */
|
||||||
function ReportCard({ img, num, lang }: { img: string; num: string; lang: 'fa' | 'en' }) {
|
function ReportCard({ img, num, fileUrl, lang }: { img: string; num: string; fileUrl?: string; lang: 'fa' | 'en' }) {
|
||||||
|
const label = lang === 'fa' ? 'دانلود ماهنامه' : 'Download'
|
||||||
|
const bar = (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#fff', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||||
|
<FileText size={14} color={GOLD} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
style={{
|
style={{
|
||||||
background: '#e9e9e9', borderRadius: 12, overflow: 'hidden', cursor: 'pointer',
|
background: '#e9e9e9', borderRadius: 12, overflow: 'hidden', cursor: fileUrl ? 'pointer' : 'default',
|
||||||
boxShadow: '0 8px 24px -16px rgba(7,29,73,0.35)', display: 'flex', flexDirection: 'column', height: '100%',
|
boxShadow: '0 8px 24px -16px rgba(7,29,73,0.35)', display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
transition: 'transform 180ms, box-shadow 180ms',
|
transition: 'transform 180ms, box-shadow 180ms',
|
||||||
}}
|
}}
|
||||||
|
|
@ -52,22 +64,46 @@ function ReportCard({ img, num, lang }: { img: string; num: string; lang: 'fa' |
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '14px 14px 8px', flexGrow: 1 }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '14px 14px 8px', flexGrow: 1 }}>
|
||||||
<img src={img} alt="" style={{ width: '100%', height: 184, objectFit: 'contain', display: 'block' }} />
|
<img src={img} alt="" style={{ width: '100%', height: 184, objectFit: 'contain', display: 'block' }} />
|
||||||
</div>
|
</div>
|
||||||
<a
|
{fileUrl ? (
|
||||||
href={MSC_URL} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}
|
<a
|
||||||
style={{ background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 12px', textDecoration: 'none', gap: 8, direction: 'ltr' }}
|
href={fileUrl} download target="_blank" rel="noopener noreferrer"
|
||||||
>
|
style={{ background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 12px', textDecoration: 'none', gap: 8, direction: 'ltr' }}
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#fff', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
>
|
||||||
<FileText size={14} color={GOLD} />
|
{bar}
|
||||||
{lang === 'fa' ? 'دانلود ماهنامه' : 'Download'}
|
{num && <span style={{ width: 26, height: 26, borderRadius: '50%', background: GOLD, color: NAVY, fontSize: 10.5, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{num}</span>}
|
||||||
</span>
|
</a>
|
||||||
<span style={{ width: 26, height: 26, borderRadius: '50%', background: GOLD, color: NAVY, fontSize: 10.5, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{num}</span>
|
) : (
|
||||||
</a>
|
<div style={{ background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 12px', gap: 8, direction: 'ltr', opacity: 0.6 }}>
|
||||||
|
{bar}
|
||||||
|
{num && <span style={{ width: 26, height: 26, borderRadius: '50%', background: GOLD, color: NAVY, fontSize: 10.5, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{num}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FactoryReportsScroll() {
|
export default function FactoryReportsScroll() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
|
const [covers, setCovers] = useState<Cover[]>(COVERS)
|
||||||
|
|
||||||
|
// pull the monthly magazines from the panel; fall back to the static covers
|
||||||
|
useEffect(() => {
|
||||||
|
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
||||||
|
let alive = true
|
||||||
|
fetch(`${PANEL_API}/api/factory-reports?limit=5`)
|
||||||
|
.then(r => (r.ok ? r.json() : null))
|
||||||
|
.then((rows) => {
|
||||||
|
if (!alive || !Array.isArray(rows) || rows.length === 0) return
|
||||||
|
setCovers(rows.map((r: Record<string, unknown>, i): Cover => ({
|
||||||
|
img: String(r.img || ''),
|
||||||
|
num: '',
|
||||||
|
dir: FALLBACK_DIRS[i % FALLBACK_DIRS.length],
|
||||||
|
fileUrl: safeFile(r.fileUrl),
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { alive = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ background: '#faf7f4', padding: '40px 0' }} dir={lang === 'fa' ? 'rtl' : 'ltr'}>
|
<section style={{ background: '#faf7f4', padding: '40px 0' }} dir={lang === 'fa' ? 'rtl' : 'ltr'}>
|
||||||
|
|
@ -79,28 +115,15 @@ export default function FactoryReportsScroll() {
|
||||||
<h2 style={{ fontSize: 'clamp(19px, 2vw, 26px)', fontWeight: 900, color: NAVY, margin: 0 }}>ماهنامه تحلیلی کارخانه</h2>
|
<h2 style={{ fontSize: 'clamp(19px, 2vw, 26px)', fontWeight: 900, color: NAVY, margin: 0 }}>ماهنامه تحلیلی کارخانه</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 5 cards + «view all» on the left */}
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 14 }} className="max-lg:!grid-cols-3 max-sm:!grid-cols-2">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 22 }} className="max-lg:!flex-col max-lg:!items-stretch max-lg:!gap-5">
|
{covers.map((c, i) => (
|
||||||
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 14 }} className="max-lg:!grid-cols-3 max-sm:!grid-cols-2">
|
// 5th+ cover is hidden on phones so the 2-col grid stays a clean 2×2 (no orphan)
|
||||||
{COVERS.map((c, i) => (
|
<div key={c.img + i} className={i >= 4 ? 'max-sm:!hidden' : undefined}>
|
||||||
// 5th+ cover is hidden on phones so the 2-col grid stays a clean 2×2 (no orphan)
|
<ScrollAnimation direction={c.dir}>
|
||||||
<div key={c.img} className={i >= 4 ? 'max-sm:!hidden' : undefined}>
|
<ReportCard img={c.img} num={c.num} fileUrl={c.fileUrl} lang={lang} />
|
||||||
<ScrollAnimation direction={c.dir}>
|
</ScrollAnimation>
|
||||||
<ReportCard img={c.img} num={c.num} lang={lang} />
|
</div>
|
||||||
</ScrollAnimation>
|
))}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={MSC_URL} target="_blank" rel="noopener noreferrer"
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, textDecoration: 'none', flexShrink: 0, width: 84 }}
|
|
||||||
className="max-lg:!flex-row max-lg:!w-auto max-lg:!justify-center"
|
|
||||||
>
|
|
||||||
<span style={{ width: 40, height: 40, borderRadius: '50%', border: `1.5px solid ${GOLD}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
||||||
<ArrowLeft size={18} color={GOLD} className="no-rtl-flip" />
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 12.5, fontWeight: 700, color: NAVY, whiteSpace: 'nowrap' }}>مشاهده همه</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ function CommentCard({ c, lang }: { c: Post['comments'][number]; lang: 'fa' | 'e
|
||||||
function MembershipGate({ lang, onPreviewLogin }: { lang: 'fa' | 'en'; onPreviewLogin: () => void }) {
|
function MembershipGate({ lang, onPreviewLogin }: { lang: 'fa' | 'en'; onPreviewLogin: () => void }) {
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
const copy = isFa
|
const copy = isFa
|
||||||
? 'ما در اندیشکده فولاد آینده، خروجیهای راهبردی خود را برای اعضای شبکه تخصصیمان تدوین میکنیم. با عضویت در این اندیشکده، میتوانید به متن کامل گزارشها، سناریوهای آیندهپژوهی و تحلیلهای سطحبالا دسترسی داشته باشید. پیوستن به این شبکه، دریچهای است برای مشارکت در گفتوگوهای تخصصی و همافزایی با سایر پیشگامان صنعت.'
|
? 'تحلیلهای راهبردی، آمارهای تخصصی و گزارشهای اختصاصی اندیشکده فولاد آینده تنها در اختیار اعضای شبکه قرار میگیرد. برای دسترسی به این محتوا، وارد حساب کاربری خود شوید یا به جمع اعضای اندیشکده بپیوندید.'
|
||||||
: 'At the Future Steel Institute we craft our strategic outputs for members of our expert network. Membership unlocks full reports, foresight scenarios and high-level analysis — and a seat in the conversation with industry pioneers.'
|
: 'At the Future Steel Institute we craft our strategic outputs for members of our expert network. Membership unlocks full reports, foresight scenarios and high-level analysis — and a seat in the conversation with industry pioneers.'
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ function Sources({ sources, lang }: { sources: { title: string; url: string }[];
|
||||||
function MembershipGate({ lang, onLogin }: { lang: 'fa' | 'en'; onLogin: () => void }) {
|
function MembershipGate({ lang, onLogin }: { lang: 'fa' | 'en'; onLogin: () => void }) {
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
const copy = isFa
|
const copy = isFa
|
||||||
? 'ما در اندیشکده فولاد آینده، خروجیهای راهبردی خود را برای اعضای شبکه تخصصیمان تدوین میکنیم. با عضویت در این اندیشکده، میتوانید به متن کامل گزارشها، سناریوهای آیندهپژوهی و تحلیلهای سطحبالا دسترسی داشته باشید.'
|
? 'تحلیلهای راهبردی، آمارهای تخصصی و گزارشهای اختصاصی اندیشکده فولاد آینده تنها در اختیار اعضای شبکه قرار میگیرد. برای دسترسی به این محتوا، وارد حساب کاربری خود شوید یا به جمع اعضای اندیشکده بپیوندید.'
|
||||||
: 'At the Future Steel Institute we craft our strategic outputs for members of our expert network. Membership unlocks full reports, foresight scenarios and high-level analysis.'
|
: 'At the Future Steel Institute we craft our strategic outputs for members of our expert network. Membership unlocks full reports, foresight scenarios and high-level analysis.'
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,7 @@ await pool.query(`
|
||||||
date_en TEXT,
|
date_en TEXT,
|
||||||
excerpt_fa TEXT,
|
excerpt_fa TEXT,
|
||||||
excerpt_en TEXT,
|
excerpt_en TEXT,
|
||||||
|
file_url TEXT,
|
||||||
scroll_dir TEXT CHECK(scroll_dir IN ('left','right','up')) DEFAULT 'up',
|
scroll_dir TEXT CHECK(scroll_dir IN ('left','right','up')) DEFAULT 'up',
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
@ -421,6 +422,7 @@ for (const sql of [
|
||||||
'ALTER TABLE members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
|
'ALTER TABLE members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
|
||||||
'ALTER TABLE members ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
|
'ALTER TABLE members ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
|
||||||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
|
'ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
|
||||||
|
'ALTER TABLE factory_reports ADD COLUMN IF NOT EXISTS file_url TEXT',
|
||||||
]) {
|
]) {
|
||||||
await pool.query(sql);
|
await pool.query(sql);
|
||||||
}
|
}
|
||||||
|
|
@ -592,6 +594,7 @@ export function rowToFactoryReport(row) {
|
||||||
dateEn: row.date_en,
|
dateEn: row.date_en,
|
||||||
excerptFa: row.excerpt_fa,
|
excerptFa: row.excerpt_fa,
|
||||||
excerptEn: row.excerpt_en,
|
excerptEn: row.excerpt_en,
|
||||||
|
fileUrl: row.file_url,
|
||||||
scrollDir: row.scroll_dir,
|
scrollDir: row.scroll_dir,
|
||||||
sortOrder: row.sort_order,
|
sortOrder: row.sort_order,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nodemailer": "^8.0.10",
|
"nodemailer": "^9.0.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"sharp": "^0.35.1"
|
"sharp": "^0.35.1"
|
||||||
},
|
},
|
||||||
|
|
@ -2177,9 +2177,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.11",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||||
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
|
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
|
|
@ -2824,9 +2824,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.27.2",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nodemailer": "^8.0.10",
|
"nodemailer": "^9.0.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"sharp": "^0.35.1"
|
"sharp": "^0.35.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
const root = $('#app');
|
const root = $('#app');
|
||||||
|
|
||||||
const TAB_GROUPS = [
|
const TAB_GROUPS = [
|
||||||
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'media', 'events', 'banners'] },
|
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'factoryReports', 'media', 'events', 'banners'] },
|
||||||
{ label: 'کاربران', tabs: ['members', 'newsletter'] },
|
{ label: 'کاربران', tabs: ['members', 'newsletter'] },
|
||||||
];
|
];
|
||||||
const TAB_LABELS = {
|
const TAB_LABELS = {
|
||||||
glance: 'در یک نگاه', radarFuture: 'رادار آینده', media: 'ویدیوکست و پادکست',
|
glance: 'در یک نگاه', radarFuture: 'رادار آینده', factoryReports: 'ماهنامه کارخانه', media: 'ویدیوکست و پادکست',
|
||||||
events: 'رویدادها', banners: 'بنرها',
|
events: 'رویدادها', banners: 'بنرها',
|
||||||
members: 'کاربران سایت', newsletter: 'خبرنامه',
|
members: 'کاربران سایت', newsletter: 'خبرنامه',
|
||||||
};
|
};
|
||||||
|
|
@ -136,7 +136,7 @@ function renderLogin(errorMsg = '') {
|
||||||
<label>رمز عبور
|
<label>رمز عبور
|
||||||
<input name="password" type="password" autocomplete="current-password" required />
|
<input name="password" type="password" autocomplete="current-password" required />
|
||||||
</label>
|
</label>
|
||||||
${errorMsg ? `<div class="error">${errorMsg}</div>` : ''}
|
${errorMsg ? `<div class="error">${escapeHtml(errorMsg)}</div>` : ''}
|
||||||
<button type="submit">ورود</button>
|
<button type="submit">ورود</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,6 +184,7 @@ function wireTabs() {
|
||||||
const t = btn.dataset.tab;
|
const t = btn.dataset.tab;
|
||||||
if (t === 'glance') renderRadarList();
|
if (t === 'glance') renderRadarList();
|
||||||
else if (t === 'radarFuture') renderRadarFutureList();
|
else if (t === 'radarFuture') renderRadarFutureList();
|
||||||
|
else if (t === 'factoryReports') renderFactoryReportsList();
|
||||||
else if (t === 'media') renderMediaList();
|
else if (t === 'media') renderMediaList();
|
||||||
else if (t === 'events') renderEventsList();
|
else if (t === 'events') renderEventsList();
|
||||||
else if (t === 'banners') renderBannersList();
|
else if (t === 'banners') renderBannersList();
|
||||||
|
|
@ -1845,7 +1846,7 @@ async function renderFactoryReportsList() {
|
||||||
|
|
||||||
function renderFactoryReportEditor(report) {
|
function renderFactoryReportEditor(report) {
|
||||||
const r = report || { img:'', tagFa:'', tagEn:'', titleFa:'', titleEn:'', dateFa:'', dateEn:'',
|
const r = report || { img:'', tagFa:'', tagEn:'', titleFa:'', titleEn:'', dateFa:'', dateEn:'',
|
||||||
excerptFa:'', excerptEn:'', scrollDir:'up', sortOrder:0 };
|
excerptFa:'', excerptEn:'', fileUrl:'', scrollDir:'up', sortOrder:0 };
|
||||||
const isEdit = !!report;
|
const isEdit = !!report;
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|
@ -1866,13 +1867,45 @@ function renderFactoryReportEditor(report) {
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${r.sortOrder||0}" /></label>
|
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${r.sortOrder||0}" /></label>
|
||||||
<div class="col span-2"><label>مسیر تصویر <input name="img" value="${escapeAttr(r.img||'')}" placeholder="/news/..." /></label></div>
|
<div class="col span-2">
|
||||||
|
<label style="display:block;margin-bottom:6px">تصویر کاور</label>
|
||||||
|
<input type="file" id="factoryCoverFile" accept="image/*" style="display:block;margin-bottom:8px" />
|
||||||
|
<input type="text" name="img" value="${escapeAttr(r.img||'')}" placeholder="آدرس تصویر (یا از دکمه بالا آپلود کنید)" style="width:100%" />
|
||||||
|
<img id="factoryCoverPreview" alt="" src="${escapeAttr(r.img||'')}" style="${r.img?'':'display:none'};max-width:200px;max-height:130px;border-radius:6px;margin-top:8px" />
|
||||||
|
</div>
|
||||||
|
<div class="col span-2">
|
||||||
|
<label style="display:block;margin-bottom:6px">فایل ماهنامه (PDF) — مستقیم دانلود میشود</label>
|
||||||
|
<input type="file" id="factoryFile" accept="application/pdf" style="display:block;margin-bottom:8px" />
|
||||||
|
<input type="text" name="fileUrl" value="${escapeAttr(r.fileUrl||'')}" placeholder="آدرس فایل (یا از دکمه بالا آپلود کنید)" style="width:100%" />
|
||||||
|
<span id="factoryFileStatus" class="muted" style="display:block;font-size:12px;margin-top:6px"></span>
|
||||||
|
</div>
|
||||||
<div class="col span-2"><label>خلاصه (فارسی) <textarea name="excerptFa" rows="3">${escapeHtml(r.excerptFa||'')}</textarea></label></div>
|
<div class="col span-2"><label>خلاصه (فارسی) <textarea name="excerptFa" rows="3">${escapeHtml(r.excerptFa||'')}</textarea></label></div>
|
||||||
<div class="col span-2"><label>خلاصه (انگلیسی) <textarea name="excerptEn" rows="3">${escapeHtml(r.excerptEn||'')}</textarea></label></div>
|
<div class="col span-2"><label>خلاصه (انگلیسی) <textarea name="excerptEn" rows="3">${escapeHtml(r.excerptEn||'')}</textarea></label></div>
|
||||||
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
|
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
|
||||||
</form>
|
</form>
|
||||||
</main>`;
|
</main>`;
|
||||||
$('#backBtn').addEventListener('click', () => renderFactoryReportsList());
|
$('#backBtn').addEventListener('click', () => renderFactoryReportsList());
|
||||||
|
document.getElementById('factoryCoverFile').addEventListener('change', async e => {
|
||||||
|
const file = e.target.files?.[0]; if (!file) return;
|
||||||
|
const fd = new FormData(); fd.append('file', file);
|
||||||
|
try {
|
||||||
|
const { url } = await api('/api/uploads', { method:'POST', body:fd, isForm:true });
|
||||||
|
$('input[name="img"]').value = url;
|
||||||
|
const prev = document.getElementById('factoryCoverPreview');
|
||||||
|
if (prev) { prev.src = url; prev.style.display = ''; }
|
||||||
|
} catch (err) { alert('آپلود تصویر ناموفق: ' + err.message); }
|
||||||
|
});
|
||||||
|
document.getElementById('factoryFile').addEventListener('change', async e => {
|
||||||
|
const file = e.target.files?.[0]; if (!file) return;
|
||||||
|
const status = document.getElementById('factoryFileStatus');
|
||||||
|
if (status) status.textContent = 'در حال آپلود فایل…';
|
||||||
|
const fd = new FormData(); fd.append('file', file);
|
||||||
|
try {
|
||||||
|
const { url } = await api('/api/uploads/raw', { method:'POST', body:fd, isForm:true });
|
||||||
|
$('input[name="fileUrl"]').value = url;
|
||||||
|
if (status) status.textContent = '✓ آپلود شد';
|
||||||
|
} catch (err) { if (status) status.textContent = ''; alert('آپلود ناموفق: ' + err.message); }
|
||||||
|
});
|
||||||
$('#editorForm').addEventListener('submit', async ev => {
|
$('#editorForm').addEventListener('submit', async ev => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const fd = new FormData(ev.target);
|
const fd = new FormData(ev.target);
|
||||||
|
|
@ -1882,6 +1915,7 @@ function renderFactoryReportEditor(report) {
|
||||||
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null,
|
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null,
|
||||||
dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null,
|
dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null,
|
||||||
excerptFa: fd.get('excerptFa')||null, excerptEn: fd.get('excerptEn')||null,
|
excerptFa: fd.get('excerptFa')||null, excerptEn: fd.get('excerptEn')||null,
|
||||||
|
fileUrl: fd.get('fileUrl')||null,
|
||||||
scrollDir: fd.get('scrollDir')||'up',
|
scrollDir: fd.get('scrollDir')||'up',
|
||||||
sortOrder: Number(fd.get('sortOrder')||0),
|
sortOrder: Number(fd.get('sortOrder')||0),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
// One-off: backfill «در یک نگاه» (radar_items) with random plausible Jalali dates,
|
||||||
|
// credible sources, category tags (so hashtags work), and category-consistent images.
|
||||||
|
// Run once: node seed-radar.js
|
||||||
|
import 'dotenv/config';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
ssl: process.env.DATABASE_SSL === 'disable' ? false : { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const FA_DIGITS = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
|
||||||
|
const faNum = (n) => String(n).replace(/\d/g, (d) => FA_DIGITS[+d]);
|
||||||
|
|
||||||
|
// Jalali month → rough Gregorian month for the English date
|
||||||
|
const MONTHS = [
|
||||||
|
{ fa: 'فروردین', en: 'Apr' }, { fa: 'اردیبهشت', en: 'May' }, { fa: 'خرداد', en: 'Jun' },
|
||||||
|
{ fa: 'تیر', en: 'Jul' }, { fa: 'مرداد', en: 'Aug' }, { fa: 'شهریور', en: 'Sep' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
'Reuters', 'Bloomberg', 'Financial Times', 'S&P Global Platts', 'Metal Bulletin',
|
||||||
|
'Fastmarkets', 'Kallanish', 'Mining.com', 'worldsteel', 'دنیای اقتصاد', 'ایسنا', 'فولاد نیوز', 'چیلان',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TAGS = {
|
||||||
|
market: ['بازار جهانی', 'قیمت فولاد', 'تقاضا', 'عرضه', 'صادرات'],
|
||||||
|
tech: ['فناوری', 'نوآوری', 'هوش مصنوعی', 'اتوماسیون', 'هیدروژن'],
|
||||||
|
commodity: ['سنگ آهن', 'زغال سنگ', 'کامودیتی', 'مواد اولیه', 'قراضه'],
|
||||||
|
geo: ['ژئوپلیتیک', 'تحریم', 'تجارت جهانی', 'زنجیره تأمین', 'CBAM'],
|
||||||
|
energy: ['انرژی', 'گاز', 'برق', 'فولاد سبز', 'کربن زدایی'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// category-relevant cover (best available stock in /public/news)
|
||||||
|
const IMG = {
|
||||||
|
market: '/news/photo-1504307651254-35680f356dfd.jpg',
|
||||||
|
tech: '/news/photo-1518770660439-4636190af475.jpg',
|
||||||
|
commodity: '/news/photo-1540575467063-178a50c2df87.jpg',
|
||||||
|
geo: '/news/photo-1570168007204-dfb528c6958f.jpg',
|
||||||
|
energy: '/news/photo-1466611653911-95081537e5b7.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
const pickN = (arr, n) => [...arr].sort(() => Math.random() - 0.5).slice(0, n);
|
||||||
|
|
||||||
|
const rows = (await pool.query('SELECT id, category, tags, img FROM radar_items')).rows;
|
||||||
|
console.log(`Updating ${rows.length} radar_items…`);
|
||||||
|
|
||||||
|
let n = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const m = pick(MONTHS);
|
||||||
|
const day = 1 + Math.floor(Math.random() * 28);
|
||||||
|
const dateFa = `${faNum(day)} ${m.fa} ۱۴۰۵`;
|
||||||
|
const dateEn = `${m.en} ${day}, 2026`;
|
||||||
|
const source = pick(SOURCES);
|
||||||
|
const cat = TAGS[r.category] ? r.category : 'market';
|
||||||
|
|
||||||
|
// keep existing tags if present; otherwise seed 3 category tags
|
||||||
|
let tags = r.tags;
|
||||||
|
try { const parsed = JSON.parse(r.tags || '[]'); if (!parsed.length) tags = null; } catch { tags = null; }
|
||||||
|
if (!tags) tags = JSON.stringify(pickN(TAGS[cat], 3));
|
||||||
|
|
||||||
|
// set a category-relevant image unless the editor uploaded one (/media/…)
|
||||||
|
const img = (r.img && r.img.startsWith('/media/')) ? r.img : IMG[cat];
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE radar_items SET date_fa=$1, date_en=$2, source=$3, tags=$4, img=$5, updated_at=now() WHERE id=$6`,
|
||||||
|
[dateFa, dateEn, source, tags, img, r.id]
|
||||||
|
);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Updated ${n} rows.`);
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
|
@ -16,7 +16,7 @@ if (existing) {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
const info = await db
|
const info = await db
|
||||||
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'owner') RETURNING id")
|
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'owner') RETURNING id")
|
||||||
.run(username, hash);
|
.run(username, hash);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { nanoid } from 'nanoid';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { randomInt, createHmac } from 'node:crypto';
|
import { randomInt, createHmac, randomUUID } from 'node:crypto';
|
||||||
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
|
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -55,8 +55,8 @@ const uploadRaw = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: 200 * 1024 * 1024 },
|
limits: { fileSize: 200 * 1024 * 1024 },
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
if (/^(video\/(mp4|webm|ogg|quicktime|x-matroska)|audio\/(mpeg|mp3|wav|x-wav|ogg|webm|aac|x-m4a|mp4))$/.test(file.mimetype)) cb(null, true);
|
if (/^(video\/(mp4|webm|ogg|quicktime|x-matroska)|audio\/(mpeg|mp3|wav|x-wav|ogg|webm|aac|x-m4a|mp4)|application\/pdf)$/.test(file.mimetype)) cb(null, true);
|
||||||
else cb(new Error('Only video or audio uploads are allowed'));
|
else cb(new Error('Only video, audio or PDF uploads are allowed'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -93,11 +93,12 @@ async function putObject(key, body, contentType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate a file by its magic bytes (content), not the client-supplied mimetype.
|
// Validate a file by its magic bytes (content), not the client-supplied mimetype.
|
||||||
// Returns the detected family ('image'|'video'|'audio') or null if not allowed.
|
// Returns the detected family ('image'|'video'|'audio'|'pdf') or null if not allowed.
|
||||||
function sniffKind(buf) {
|
function sniffKind(buf) {
|
||||||
if (!buf || buf.length < 12) return null;
|
if (!buf || buf.length < 12) return null;
|
||||||
const b = buf;
|
const b = buf;
|
||||||
const hex = (n) => b.subarray(0, n).toString('hex');
|
const hex = (n) => b.subarray(0, n).toString('hex');
|
||||||
|
if (b.subarray(0, 4).toString('ascii') === '%PDF') return 'pdf'; // PDF
|
||||||
if (hex(3) === 'ffd8ff') return 'image'; // JPEG
|
if (hex(3) === 'ffd8ff') return 'image'; // JPEG
|
||||||
if (hex(8) === '89504e470d0a1a0a') return 'image'; // PNG
|
if (hex(8) === '89504e470d0a1a0a') return 'image'; // PNG
|
||||||
if (b.subarray(0, 4).toString('ascii') === 'GIF8') return 'image'; // GIF
|
if (b.subarray(0, 4).toString('ascii') === 'GIF8') return 'image'; // GIF
|
||||||
|
|
@ -133,7 +134,9 @@ const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173')
|
||||||
// the panel serves its own SPA, whose module scripts fetch same-origin with an Origin
|
// the panel serves its own SPA, whose module scripts fetch same-origin with an Origin
|
||||||
// header; rejecting that 500s app.js. Disallowed origins get cb(null,false) (no CORS
|
// header; rejecting that 500s app.js. Disallowed origins get cb(null,false) (no CORS
|
||||||
// headers) rather than a thrown error, so a bad cross-origin request never 500s static assets.
|
// headers) rather than a thrown error, so a bad cross-origin request never 500s static assets.
|
||||||
const isLocalhost = (o) => /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o);
|
// localhost (any port) is allowed only in dev — in production it would widen the
|
||||||
|
// credentialed-CORS allowlist to any local service on a victim's machine.
|
||||||
|
const isLocalhost = (o) => process.env.NODE_ENV !== 'production' && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o);
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: (origin, cb) => {
|
origin: (origin, cb) => {
|
||||||
cb(null, !origin || ALLOWED_ORIGINS.includes(origin) || isLocalhost(origin));
|
cb(null, !origin || ALLOWED_ORIGINS.includes(origin) || isLocalhost(origin));
|
||||||
|
|
@ -382,9 +385,9 @@ app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) =
|
||||||
app.post('/api/uploads/raw', authRequired, uploadRaw.single('file'), async (req, res) => {
|
app.post('/api/uploads/raw', authRequired, uploadRaw.single('file'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'no_file' });
|
if (!req.file) return res.status(400).json({ error: 'no_file' });
|
||||||
if (!s3) return res.status(503).json({ error: 'object_storage_not_configured' });
|
if (!s3) return res.status(503).json({ error: 'object_storage_not_configured' });
|
||||||
// validate by content (magic bytes); only real video/audio is accepted
|
// validate by content (magic bytes); only real video/audio/pdf is accepted
|
||||||
const kind = sniffKind(req.file.buffer);
|
const kind = sniffKind(req.file.buffer);
|
||||||
if (kind !== 'video' && kind !== 'audio') return res.status(415).json({ error: 'invalid_media' });
|
if (kind !== 'video' && kind !== 'audio' && kind !== 'pdf') return res.status(415).json({ error: 'invalid_media' });
|
||||||
try {
|
try {
|
||||||
const ext = (req.file.originalname.match(/\.[a-z0-9]+$/i)?.[0] || '').toLowerCase();
|
const ext = (req.file.originalname.match(/\.[a-z0-9]+$/i)?.[0] || '').toLowerCase();
|
||||||
const key = `${nanoid(12)}${ext}`;
|
const key = `${nanoid(12)}${ext}`;
|
||||||
|
|
@ -772,7 +775,7 @@ app.put('/api/radar-pages/:slug', authRequired, async (req, res) => {
|
||||||
|
|
||||||
// ---------- factory reports ----------
|
// ---------- factory reports ----------
|
||||||
const FACTORY_REPORT_FIELDS = `id, img, tag_fa, tag_en, title_fa, title_en, date_fa, date_en,
|
const FACTORY_REPORT_FIELDS = `id, img, tag_fa, tag_en, title_fa, title_en, date_fa, date_en,
|
||||||
excerpt_fa, excerpt_en, scroll_dir, sort_order, created_at, updated_at`;
|
excerpt_fa, excerpt_en, file_url, scroll_dir, sort_order, created_at, updated_at`;
|
||||||
|
|
||||||
app.get('/api/factory-reports', async (req, res) => {
|
app.get('/api/factory-reports', async (req, res) => {
|
||||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||||
|
|
@ -794,6 +797,7 @@ function normalizeFactoryReportBody(b) {
|
||||||
title_en: b.titleEn || null,
|
title_en: b.titleEn || null,
|
||||||
date_fa: b.dateFa || null, date_en: b.dateEn || null,
|
date_fa: b.dateFa || null, date_en: b.dateEn || null,
|
||||||
excerpt_fa: b.excerptFa || null, excerpt_en: b.excerptEn || null,
|
excerpt_fa: b.excerptFa || null, excerpt_en: b.excerptEn || null,
|
||||||
|
file_url: b.fileUrl || null,
|
||||||
scroll_dir: ['left','right','up'].includes(b.scrollDir) ? b.scrollDir : 'up',
|
scroll_dir: ['left','right','up'].includes(b.scrollDir) ? b.scrollDir : 'up',
|
||||||
sort_order: Number(b.sortOrder) || 0,
|
sort_order: Number(b.sortOrder) || 0,
|
||||||
};
|
};
|
||||||
|
|
@ -804,9 +808,9 @@ app.post('/api/factory-reports', authRequired, async (req, res) => {
|
||||||
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
|
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
|
||||||
const id = nanoid(14);
|
const id = nanoid(14);
|
||||||
await db.prepare(`INSERT INTO factory_reports (id,img,tag_fa,tag_en,title_fa,title_en,date_fa,date_en,
|
await db.prepare(`INSERT INTO factory_reports (id,img,tag_fa,tag_en,title_fa,title_en,date_fa,date_en,
|
||||||
excerpt_fa,excerpt_en,scroll_dir,sort_order)
|
excerpt_fa,excerpt_en,file_url,scroll_dir,sort_order)
|
||||||
VALUES (@id,@img,@tag_fa,@tag_en,@title_fa,@title_en,@date_fa,@date_en,
|
VALUES (@id,@img,@tag_fa,@tag_en,@title_fa,@title_en,@date_fa,@date_en,
|
||||||
@excerpt_fa,@excerpt_en,@scroll_dir,@sort_order)
|
@excerpt_fa,@excerpt_en,@file_url,@scroll_dir,@sort_order)
|
||||||
`).run({ id, ...r });
|
`).run({ id, ...r });
|
||||||
res.status(201).json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(id)));
|
res.status(201).json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(id)));
|
||||||
});
|
});
|
||||||
|
|
@ -817,7 +821,7 @@ app.put('/api/factory-reports/:id', authRequired, async (req, res) => {
|
||||||
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
|
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
|
||||||
await db.prepare(`UPDATE factory_reports SET img=@img,tag_fa=@tag_fa,tag_en=@tag_en,title_fa=@title_fa,title_en=@title_en,
|
await db.prepare(`UPDATE factory_reports SET img=@img,tag_fa=@tag_fa,tag_en=@tag_en,title_fa=@title_fa,title_en=@title_en,
|
||||||
date_fa=@date_fa,date_en=@date_en,excerpt_fa=@excerpt_fa,excerpt_en=@excerpt_en,
|
date_fa=@date_fa,date_en=@date_en,excerpt_fa=@excerpt_fa,excerpt_en=@excerpt_en,
|
||||||
scroll_dir=@scroll_dir,sort_order=@sort_order,updated_at=now() WHERE id=@id
|
file_url=@file_url,scroll_dir=@scroll_dir,sort_order=@sort_order,updated_at=now() WHERE id=@id
|
||||||
`).run({ id: req.params.id, ...r });
|
`).run({ id: req.params.id, ...r });
|
||||||
res.json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id)));
|
res.json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id)));
|
||||||
});
|
});
|
||||||
|
|
@ -908,7 +912,7 @@ function normalizeInstituteStatBody(b) {
|
||||||
app.post('/api/institute-stats', authRequired, async (req, res) => {
|
app.post('/api/institute-stats', authRequired, async (req, res) => {
|
||||||
const s = normalizeInstituteStatBody(req.body || {});
|
const s = normalizeInstituteStatBody(req.body || {});
|
||||||
if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' });
|
if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' });
|
||||||
const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14);
|
const id = nanoid(14);
|
||||||
await db.prepare(`INSERT INTO institute_stats (id,label_fa,label_en,value,suffix_fa,sort_order)
|
await db.prepare(`INSERT INTO institute_stats (id,label_fa,label_en,value,suffix_fa,sort_order)
|
||||||
VALUES (@id,@label_fa,@label_en,@value,@suffix_fa,@sort_order)
|
VALUES (@id,@label_fa,@label_en,@value,@suffix_fa,@sort_order)
|
||||||
`).run({ id, ...s });
|
`).run({ id, ...s });
|
||||||
|
|
@ -959,7 +963,7 @@ function normalizeMarketPriceBody(b) {
|
||||||
app.post('/api/market-prices', authRequired, async (req, res) => {
|
app.post('/api/market-prices', authRequired, async (req, res) => {
|
||||||
const p = normalizeMarketPriceBody(req.body || {});
|
const p = normalizeMarketPriceBody(req.body || {});
|
||||||
if (!p.name) return res.status(400).json({ error: 'name_required' });
|
if (!p.name) return res.status(400).json({ error: 'name_required' });
|
||||||
const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14);
|
const id = nanoid(14);
|
||||||
await db.prepare(`INSERT INTO market_prices (id,name,value,unit,change,change_percent,trend,sort_order)
|
await db.prepare(`INSERT INTO market_prices (id,name,value,unit,change,change_percent,trend,sort_order)
|
||||||
VALUES (@id,@name,@value,@unit,@change,@change_percent,@trend,@sort_order)
|
VALUES (@id,@name,@value,@unit,@change,@change_percent,@trend,@sort_order)
|
||||||
`).run({ id, ...p });
|
`).run({ id, ...p });
|
||||||
|
|
@ -1021,7 +1025,7 @@ app.post('/api/market-chart-points', authRequired, async (req, res) => {
|
||||||
const c = normalizeChartPointBody(req.body || {});
|
const c = normalizeChartPointBody(req.body || {});
|
||||||
if (!c.series) return res.status(400).json({ error: 'series_required' });
|
if (!c.series) return res.status(400).json({ error: 'series_required' });
|
||||||
if (!c.label) return res.status(400).json({ error: 'label_required' });
|
if (!c.label) return res.status(400).json({ error: 'label_required' });
|
||||||
const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14);
|
const id = nanoid(14);
|
||||||
await db.prepare(`INSERT INTO market_chart_points (id,series,label,value,meta,sort_order)
|
await db.prepare(`INSERT INTO market_chart_points (id,series,label,value,meta,sort_order)
|
||||||
VALUES (@id,@series,@label,@value,@meta,@sort_order)
|
VALUES (@id,@series,@label,@value,@meta,@sort_order)
|
||||||
`).run({ id, ...c });
|
`).run({ id, ...c });
|
||||||
|
|
@ -1333,7 +1337,7 @@ app.delete('/api/risks/:id', authRequired, async (req, res) => {
|
||||||
|
|
||||||
// ---------- users (admin management) ----------
|
// ---------- users (admin management) ----------
|
||||||
// password_hash is NEVER returned to the client.
|
// password_hash is NEVER returned to the client.
|
||||||
app.get('/api/users', authRequired, async (_req, res) => {
|
app.get('/api/users', authRequired, ownerRequired, async (_req, res) => {
|
||||||
const rows = await db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id LIMIT 1000').all();
|
const rows = await db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id LIMIT 1000').all();
|
||||||
res.json(rows.map((r) => ({ id: r.id, username: r.username, role: r.role, createdAt: r.created_at })));
|
res.json(rows.map((r) => ({ id: r.id, username: r.username, role: r.role, createdAt: r.created_at })));
|
||||||
});
|
});
|
||||||
|
|
@ -1345,7 +1349,7 @@ app.post('/api/users', authRequired, ownerRequired, async (req, res) => {
|
||||||
if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
|
if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
|
||||||
const exists = await db.prepare('SELECT 1 FROM users WHERE username = ?').get(username);
|
const exists = await db.prepare('SELECT 1 FROM users WHERE username = ?').get(username);
|
||||||
if (exists) return res.status(409).json({ error: 'username_taken' });
|
if (exists) return res.status(409).json({ error: 'username_taken' });
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
const role = req.body?.role === 'owner' ? 'owner' : 'admin';
|
const role = req.body?.role === 'owner' ? 'owner' : 'admin';
|
||||||
const info = await db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id').run(username, hash, role);
|
const info = await db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id').run(username, hash, role);
|
||||||
res.status(201).json({ id: info.lastInsertRowid, username, role });
|
res.status(201).json({ id: info.lastInsertRowid, username, role });
|
||||||
|
|
@ -1367,7 +1371,7 @@ app.put('/api/users/:id', authRequired, async (req, res) => {
|
||||||
}
|
}
|
||||||
if (password) {
|
if (password) {
|
||||||
if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
|
if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
await db.prepare('UPDATE users SET username = ?, password_hash = ?, token_version = COALESCE(token_version,0) + 1 WHERE id = ?').run(username, hash, id);
|
await db.prepare('UPDATE users SET username = ?, password_hash = ?, token_version = COALESCE(token_version,0) + 1 WHERE id = ?').run(username, hash, id);
|
||||||
} else {
|
} else {
|
||||||
await db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username, id);
|
await db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username, id);
|
||||||
|
|
@ -1478,6 +1482,15 @@ app.get('/api/banners/:id', async (req, res) => {
|
||||||
res.json(rowToBanner(row));
|
res.json(rowToBanner(row));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// accept only relative paths or absolute http(s) URLs; null out javascript:/data:/etc.
|
||||||
|
function safeHref(u) {
|
||||||
|
const s = String(u || '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.startsWith('/')) return s;
|
||||||
|
return /^https?:\/\//i.test(s) ? s : null;
|
||||||
|
}
|
||||||
|
const BANNER_POSITIONS = ['hero', 'sidebar', 'footer'];
|
||||||
|
|
||||||
function normalizeBannerBody(b) {
|
function normalizeBannerBody(b) {
|
||||||
return {
|
return {
|
||||||
overline_fa: b.overlineFa || null,
|
overline_fa: b.overlineFa || null,
|
||||||
|
|
@ -1488,9 +1501,9 @@ function normalizeBannerBody(b) {
|
||||||
subtitle_en: b.subtitleEn || null,
|
subtitle_en: b.subtitleEn || null,
|
||||||
cta_label_fa: b.ctaLabelFa || null,
|
cta_label_fa: b.ctaLabelFa || null,
|
||||||
cta_label_en: b.ctaLabelEn || null,
|
cta_label_en: b.ctaLabelEn || null,
|
||||||
cta_url: b.ctaUrl || null,
|
cta_url: safeHref(b.ctaUrl),
|
||||||
image: b.image || null,
|
image: b.image || null,
|
||||||
position: b.position || 'hero',
|
position: BANNER_POSITIONS.includes(b.position) ? b.position : 'hero',
|
||||||
active: b.active !== false ? 1 : 0,
|
active: b.active !== false ? 1 : 0,
|
||||||
sort_order: Number(b.sortOrder) || 0,
|
sort_order: Number(b.sortOrder) || 0,
|
||||||
};
|
};
|
||||||
|
|
@ -1533,7 +1546,9 @@ function escHtml(s) {
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post('/api/contact', contactLimiter, async (req, res) => {
|
app.post('/api/contact', contactLimiter, async (req, res) => {
|
||||||
const { name, phone, email } = req.body || {};
|
const name = String(req.body?.name || '').trim().slice(0, 200);
|
||||||
|
const phone = String(req.body?.phone || '').trim().slice(0, 40);
|
||||||
|
const email = String(req.body?.email || '').trim().slice(0, 254);
|
||||||
if (!name || !phone) return res.status(400).json({ error: 'name and phone required' });
|
if (!name || !phone) return res.status(400).json({ error: 'name and phone required' });
|
||||||
|
|
||||||
const mailTo = process.env.MAIL_TO;
|
const mailTo = process.env.MAIL_TO;
|
||||||
|
|
@ -1624,7 +1639,7 @@ app.post('/api/members/register', loginLimiter, async (req, res) => {
|
||||||
if (password.length < 8) return res.status(400).json({ error: 'رمز عبور باید حداقل ۸ کاراکتر باشد' });
|
if (password.length < 8) return res.status(400).json({ error: 'رمز عبور باید حداقل ۸ کاراکتر باشد' });
|
||||||
const exists = await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email);
|
const exists = await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email);
|
||||||
if (exists) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' });
|
if (exists) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' });
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id').run(name.trim(), email.toLowerCase().trim(), hash, phone ? String(phone).trim() : null);
|
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id').run(name.trim(), email.toLowerCase().trim(), hash, phone ? String(phone).trim() : null);
|
||||||
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member', tv: 0 }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member', tv: 0 }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
res.cookie('member_session', token, memberCookieOpts(req));
|
res.cookie('member_session', token, memberCookieOpts(req));
|
||||||
|
|
@ -1672,8 +1687,11 @@ app.get('/api/sso/statista', async (req, res) => {
|
||||||
// honor deactivation / logout-revocation just like memberRequired does
|
// honor deactivation / logout-revocation just like memberRequired does
|
||||||
const acct = await db.prepare('SELECT is_active, token_version FROM members WHERE id = ?').get(p.sub);
|
const acct = await db.prepare('SELECT is_active, token_version FROM members WHERE id = ?').get(p.sub);
|
||||||
if (!acct || !acct.is_active || (acct.token_version || 0) !== (p.tv || 0)) return fail();
|
if (!acct || !acct.is_active || (acct.token_version || 0) !== (p.tv || 0)) return fail();
|
||||||
|
// aud pins the token to statista; jti makes it single-use (statista records it);
|
||||||
|
// 60s exp keeps the replay window tiny. All three are enforced on the consumer.
|
||||||
const body = Buffer.from(JSON.stringify({
|
const body = Buffer.from(JSON.stringify({
|
||||||
email: p.email, name: p.name, exp: Math.floor(Date.now() / 1000) + 120,
|
email: p.email, name: p.name, aud: 'statista', jti: randomUUID(),
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60,
|
||||||
})).toString('base64url');
|
})).toString('base64url');
|
||||||
const sig = createHmac('sha256', SSO_SECRET).update(body).digest('base64url');
|
const sig = createHmac('sha256', SSO_SECRET).update(body).digest('base64url');
|
||||||
res.redirect(`${STATISTA_URL}/api/sso?t=${body}.${sig}`);
|
res.redirect(`${STATISTA_URL}/api/sso?t=${body}.${sig}`);
|
||||||
|
|
@ -1740,7 +1758,7 @@ app.post('/api/members/otp/verify', loginLimiter, async (req, res) => {
|
||||||
let member = await db.prepare('SELECT id, name, email, is_active, token_version FROM members WHERE phone = ?').get(phone);
|
let member = await db.prepare('SELECT id, name, email, is_active, token_version FROM members WHERE phone = ?').get(phone);
|
||||||
if (!member) {
|
if (!member) {
|
||||||
// auto-signup: create a member keyed by phone (random password, placeholder email)
|
// auto-signup: create a member keyed by phone (random password, placeholder email)
|
||||||
const hash = bcrypt.hashSync(nanoid(16), 10);
|
const hash = bcrypt.hashSync(nanoid(16), 12);
|
||||||
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id')
|
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id')
|
||||||
.run('کاربر جدید', `${phone}@otp.fstt.ir`, hash, phone);
|
.run('کاربر جدید', `${phone}@otp.fstt.ir`, hash, phone);
|
||||||
member = { id: info.lastInsertRowid, name: 'کاربر جدید', email: `${phone}@otp.fstt.ir`, is_active: true, token_version: 0 };
|
member = { id: info.lastInsertRowid, name: 'کاربر جدید', email: `${phone}@otp.fstt.ir`, is_active: true, token_version: 0 };
|
||||||
|
|
@ -1762,9 +1780,16 @@ app.post('/api/members/register-verify', loginLimiter, async (req, res) => {
|
||||||
if (!checkOtp(phone, code)) return res.status(401).json({ error: 'کد تأیید نامعتبر یا منقضی است' });
|
if (!checkOtp(phone, code)) return res.status(401).json({ error: 'کد تأیید نامعتبر یا منقضی است' });
|
||||||
if (await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email.toLowerCase().trim())) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' });
|
if (await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email.toLowerCase().trim())) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' });
|
||||||
if (await db.prepare('SELECT 1 FROM members WHERE phone = ?').get(phone)) return res.status(409).json({ error: 'این شماره موبایل قبلاً ثبت شده است' });
|
if (await db.prepare('SELECT 1 FROM members WHERE phone = ?').get(phone)) return res.status(409).json({ error: 'این شماره موبایل قبلاً ثبت شده است' });
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id')
|
let info;
|
||||||
.run(name.trim(), email.toLowerCase().trim(), hash, phone);
|
try {
|
||||||
|
info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id')
|
||||||
|
.run(name.trim(), email.toLowerCase().trim(), hash, phone);
|
||||||
|
} catch (err) {
|
||||||
|
// concurrent request won the UNIQUE(email)/UNIQUE(phone) race — report cleanly, not 500
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'این ایمیل یا شماره موبایل قبلاً ثبت شده است' });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member', tv: 0 }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member', tv: 0 }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
res.cookie('member_session', token, memberCookieOpts(req));
|
res.cookie('member_session', token, memberCookieOpts(req));
|
||||||
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
|
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
|
||||||
|
|
@ -1797,7 +1822,7 @@ app.post('/api/members/me/password-otp/set', memberRequired, jsonOnly, async (re
|
||||||
if (!newPassword || String(newPassword).length < 8) return res.status(400).json({ error: 'رمز جدید باید حداقل ۸ کاراکتر باشد' });
|
if (!newPassword || String(newPassword).length < 8) return res.status(400).json({ error: 'رمز جدید باید حداقل ۸ کاراکتر باشد' });
|
||||||
const row = await db.prepare('SELECT phone, email, name FROM members WHERE id = ?').get(req.member.sub);
|
const row = await db.prepare('SELECT phone, email, name FROM members WHERE id = ?').get(req.member.sub);
|
||||||
if (!row?.phone || !checkOtp(row.phone, code)) return res.status(401).json({ error: 'کد نامعتبر یا منقضی است' });
|
if (!row?.phone || !checkOtp(row.phone, code)) return res.status(401).json({ error: 'کد نامعتبر یا منقضی است' });
|
||||||
const hash = bcrypt.hashSync(String(newPassword), 10);
|
const hash = bcrypt.hashSync(String(newPassword), 12);
|
||||||
// bump token_version (revoke other sessions) and re-issue this session's cookie
|
// bump token_version (revoke other sessions) and re-issue this session's cookie
|
||||||
const upd = await db.prepare("UPDATE members SET password_hash = ?, token_version = COALESCE(token_version,0)+1, updated_at = now() WHERE id = ? RETURNING token_version").get(hash, req.member.sub);
|
const upd = await db.prepare("UPDATE members SET password_hash = ?, token_version = COALESCE(token_version,0)+1, updated_at = now() WHERE id = ? RETURNING token_version").get(hash, req.member.sub);
|
||||||
const token = jwt.sign({ sub: req.member.sub, email: row.email, name: row.name, role: 'member', tv: upd.token_version }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ sub: req.member.sub, email: row.email, name: row.name, role: 'member', tv: upd.token_version }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
|
|
@ -1827,7 +1852,7 @@ app.put('/api/members/me/password', memberRequired, jsonOnly, async (req, res) =
|
||||||
if (newPassword.length < 8) return res.status(400).json({ error: 'رمز جدید باید حداقل ۸ کاراکتر باشد' });
|
if (newPassword.length < 8) return res.status(400).json({ error: 'رمز جدید باید حداقل ۸ کاراکتر باشد' });
|
||||||
const row = await db.prepare('SELECT password_hash, email, name FROM members WHERE id = ?').get(req.member.sub);
|
const row = await db.prepare('SELECT password_hash, email, name FROM members WHERE id = ?').get(req.member.sub);
|
||||||
if (!row || !bcrypt.compareSync(currentPassword, row.password_hash)) return res.status(401).json({ error: 'رمز فعلی اشتباه است' });
|
if (!row || !bcrypt.compareSync(currentPassword, row.password_hash)) return res.status(401).json({ error: 'رمز فعلی اشتباه است' });
|
||||||
const hash = bcrypt.hashSync(newPassword, 10);
|
const hash = bcrypt.hashSync(newPassword, 12);
|
||||||
const upd = await db.prepare("UPDATE members SET password_hash = ?, token_version = COALESCE(token_version,0)+1, updated_at = now() WHERE id = ? RETURNING token_version").get(hash, req.member.sub);
|
const upd = await db.prepare("UPDATE members SET password_hash = ?, token_version = COALESCE(token_version,0)+1, updated_at = now() WHERE id = ? RETURNING token_version").get(hash, req.member.sub);
|
||||||
const token = jwt.sign({ sub: req.member.sub, email: row.email, name: row.name, role: 'member', tv: upd.token_version }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ sub: req.member.sub, email: row.email, name: row.name, role: 'member', tv: upd.token_version }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
res.cookie('member_session', token, memberCookieOpts(req));
|
res.cookie('member_session', token, memberCookieOpts(req));
|
||||||
|
|
@ -1880,6 +1905,12 @@ app.get('/api/members/me/purchases/:purchaseId/download', memberRequired, async
|
||||||
res.download(filePath, row.title + path.extname(filePath));
|
res.download(filePath, row.title + path.extname(filePath));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// terminal error handler — never leak stack traces / SQL to the client
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).json({ error: 'internal_error' });
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Panel running at ${PUBLIC_ORIGIN}`);
|
console.log(`Panel running at ${PUBLIC_ORIGIN}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue