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:
alireza 2026-06-29 17:06:42 +03:30
parent 5a3d405ff4
commit 245097c0f2
20 changed files with 541 additions and 314 deletions

View File

@ -45,13 +45,13 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@ -60,9 +60,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@ -70,21 +70,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@ -101,14 +101,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@ -118,14 +118,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@ -135,9 +135,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@ -145,29 +145,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@ -177,9 +177,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@ -187,9 +187,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@ -197,9 +197,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@ -207,27 +207,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@ -246,33 +246,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@ -280,14 +280,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@ -300,21 +300,21 @@
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
@ -323,9 +323,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@ -596,14 +596,14 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
@ -615,9 +615,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
"version": "0.137.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
"dev": true,
"license": "MIT",
"funding": {
@ -746,9 +746,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
"cpu": [
"arm64"
],
@ -763,9 +763,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
"cpu": [
"arm64"
],
@ -780,9 +780,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
"cpu": [
"x64"
],
@ -797,9 +797,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
"cpu": [
"x64"
],
@ -814,9 +814,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
"cpu": [
"arm"
],
@ -831,9 +831,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
"cpu": [
"arm64"
],
@ -848,9 +848,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
"cpu": [
"arm64"
],
@ -865,9 +865,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
"cpu": [
"ppc64"
],
@ -882,9 +882,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
"cpu": [
"s390x"
],
@ -899,9 +899,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
"cpu": [
"x64"
],
@ -916,9 +916,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
"cpu": [
"x64"
],
@ -933,9 +933,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
"cpu": [
"arm64"
],
@ -950,9 +950,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
"cpu": [
"wasm32"
],
@ -960,18 +960,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
"cpu": [
"arm64"
],
@ -986,9 +986,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
"cpu": [
"x64"
],
@ -1331,9 +1331,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
@ -1817,9 +1817,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -1852,9 +1852,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
"dev": true,
"funding": [
{
@ -1872,10 +1872,10 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"electron-to-chromium": "^1.5.376",
"node-releases": "^2.0.48",
"update-browserslist-db": "^1.2.3"
},
"bin": {
@ -1923,9 +1923,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"dev": true,
"funding": [
{
@ -2262,9 +2262,9 @@
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.361",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
"integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==",
"version": "1.5.381",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
"integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
"dev": true,
"license": "ISC"
},
@ -3427,9 +3427,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
"dev": true,
"license": "MIT",
"engines": {
@ -3782,13 +3782,13 @@
"license": "Unlicense"
},
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.132.0",
"@oxc-project/types": "=0.137.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@ -3798,21 +3798,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.2",
"@rolldown/binding-darwin-arm64": "1.0.2",
"@rolldown/binding-darwin-x64": "1.0.2",
"@rolldown/binding-freebsd-x64": "1.0.2",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
"@rolldown/binding-linux-arm64-musl": "1.0.2",
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
"@rolldown/binding-linux-x64-gnu": "1.0.2",
"@rolldown/binding-linux-x64-musl": "1.0.2",
"@rolldown/binding-openharmony-arm64": "1.0.2",
"@rolldown/binding-wasm32-wasi": "1.0.2",
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
"@rolldown/binding-win32-x64-msvc": "1.0.2"
"@rolldown/binding-android-arm64": "1.1.3",
"@rolldown/binding-darwin-arm64": "1.1.3",
"@rolldown/binding-darwin-x64": "1.1.3",
"@rolldown/binding-freebsd-x64": "1.1.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
"@rolldown/binding-linux-arm64-gnu": "1.1.3",
"@rolldown/binding-linux-arm64-musl": "1.1.3",
"@rolldown/binding-linux-ppc64-gnu": "1.1.3",
"@rolldown/binding-linux-s390x-gnu": "1.1.3",
"@rolldown/binding-linux-x64-gnu": "1.1.3",
"@rolldown/binding-linux-x64-musl": "1.1.3",
"@rolldown/binding-openharmony-arm64": "1.1.3",
"@rolldown/binding-wasm32-wasi": "1.1.3",
"@rolldown/binding-win32-arm64-msvc": "1.1.3",
"@rolldown/binding-win32-x64-msvc": "1.1.3"
}
},
"node_modules/scheduler": {
@ -4090,9 +4090,9 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4310,17 +4310,17 @@
}
},
"node_modules/vite": {
"version": "8.0.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "1.0.2",
"tinyglobby": "^0.2.16"
"rolldown": "~1.1.2",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
@ -4336,7 +4336,7 @@
},
"peerDependencies": {
"@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",
"jiti": ">=1.21.0",
"less": "^4.0.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'
import type { CSSProperties } from 'react'
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 { useLang } from '@/context/LangContext'
import { useAuth } from '@/context/AuthContext'
@ -31,9 +31,9 @@ const SECTIONS: Section[] = [
/* ─── dropdown sections under رسانه و رویداد ─── */
const MEDIA: Section[] = [
{ fa: 'ویدیوکست و پادکست', en: 'Videocast & Podcast', to: '/podcast', Icon: Video },
{ fa: 'نشست‌های تخصصی', en: 'Expert Sessions', to: '/specialized-meetings', Icon: Mic },
{ fa: 'رویدادها', en: 'Events', to: '/events', Icon: CalendarDays },
{ fa: 'ویدیوکست و پادکست', en: 'Videocast & Podcast', to: '/podcast', Icon: Video },
{ fa: 'رویدادهای صنعت فولاد', en: 'Steel Industry Events', to: '/events', Icon: CalendarDays },
{ fa: 'تقویم اندیشکده', en: 'Institute Calendar', to: '/events#calendar', Icon: CalendarDays },
]
/* ─── dropdown sections under درباره ما ─── */

View File

@ -37,7 +37,8 @@ function toMember(u: Partial<Member> & { id: number; name: string; email: string
function load(): AuthState {
try {
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 */ }
return { member: null }
}

View File

@ -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). */
export function bannerFor(position: string): Banner | undefined {
return banners.find((b) => b.active && b.position === position)
@ -61,7 +65,7 @@ export async function bootstrapBanners(): Promise<void> {
position: String(b.position ?? 'featured'),
active: Boolean(b.active),
image: String(b.image ?? ''),
ctaUrl: String(b.ctaUrl ?? ''),
ctaUrl: safeUrl(b.ctaUrl),
fa: {
overline: String(b.overlineFa ?? ''),
title: String(b.titleFa ?? ''),

View File

@ -189,7 +189,7 @@ function MemberCard({ m, selected, onClick }: { m: { photo: string; name: string
}}>
{/* framed photo */}
<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>
{/* 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%)` }} />

View File

@ -1,6 +1,6 @@
import { useState, useEffect, type CSSProperties } from 'react'
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'
const NAVY = '#032340'
@ -219,25 +219,33 @@ const CARDS: Card[] = [
]
const NEWS_ITEMS = [
{ titleFa: 'فولاد مبارکه رکورد تولید ماهانه را شکست', titleEn: 'Mobarakeh Steel breaks monthly production record', dateFa: '۱۴ اردیبهشت', dateEn: 'May 4' },
{ titleFa: 'تفاهم‌نامه همکاری ایران و روسیه در حوزه فولاد', titleEn: 'Iran-Russia steel cooperation MOU signed', dateFa: '۱۲ اردیبهشت', dateEn: 'May 2' },
{ titleFa: 'افزایش ۸ درصدی صادرات فولاد در فروردین', titleEn: '8% rise in steel exports in April', dateFa: '۱۰ اردیبهشت', dateEn: 'Apr 30' },
{ titleFa: 'نشست وزرای صنعت کشورهای عضو ECO', titleEn: 'ECO member states industry ministers summit', dateFa: '۸ اردیبهشت', dateEn: 'Apr 28' },
{ titleFa: 'راه‌اندازی خط تولید ورق گالوانیزه در اصفهان', titleEn: 'Galvanized sheet production line launched in Isfahan', dateFa: '۶ اردیبهشت', dateEn: 'Apr 26' },
{ 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, 2026' },
{ 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, 2026' },
{ titleFa: 'راه‌اندازی خط تولید ورق گالوانیزه در اصفهان', titleEn: 'Galvanized sheet production line launched in Isfahan', dateFa: '۶ اردیبهشت ۱۴۰۵', dateEn: 'Apr 26, 2026' },
]
/* ─── 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 }}>
<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>
</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 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 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' }
@ -260,9 +268,6 @@ function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onC
{/* cover */}
<div style={{ position: 'relative', height: 284, minHeight: 284 }}>
<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>
{/* content */}
@ -288,7 +293,7 @@ function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onC
{/* اصل رویداد */}
<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 }}>
{body.map((p, i) => <p key={i} style={bodyText}>{p}</p>)}
</div>
@ -296,14 +301,18 @@ function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onC
{/* دلالت‌های راهبردی */}
<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>
</div>
{/* tags */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 20 }}>
{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>
@ -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 ─── */
export default function AtAGlance() {
const { lang } = useLang()
@ -348,8 +374,12 @@ export default function AtAGlance() {
const [activeTab, setActiveTab] = useState<Tab | 'all'>('all')
const [selectedCard, setSelectedCard] = useState<Card | null>(null)
const [showAllList, setShowAllList] = useState(false)
const [tagFilter, setTagFilter] = useState<string | null>(null)
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
useEffect(() => {
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 }
}, [])
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 smallItems = filteredCards.slice(1, 4)
const listCards = filteredCards.slice(1)
@ -452,6 +484,7 @@ export default function AtAGlance() {
<div><Badge label={catLabel(card.tab, isFa)} /></div>
<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>
<TagRow tags={card.tags} onTagClick={handleTagClick} />
</div>
</div>
))}
@ -468,6 +501,16 @@ export default function AtAGlance() {
<span style={{ width: 3, height: 26, background: GOLD, borderRadius: 2 }} />
<h2 style={{ fontSize: 20, fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'آخرین تحولات' : 'Latest Developments'}</h2>
</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={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{visibleList.map(card => (
@ -480,6 +523,7 @@ export default function AtAGlance() {
</div>
<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>
<TagRow tags={card.tags} onTagClick={handleTagClick} />
</div>
</div>
))}
@ -505,9 +549,9 @@ export default function AtAGlance() {
<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)' }}>
<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 => (
<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>
{archive.length > 0 && (
@ -551,7 +595,7 @@ export default function AtAGlance() {
{/* modal */}
<AnimatePresence>
{selectedCard && (
<CardModal card={selectedCard} lang={lang} onClose={() => setSelectedCard(null)} />
<CardModal card={selectedCard} lang={lang} onClose={() => setSelectedCard(null)} onTagClick={handleTagClick} />
)}
</AnimatePresence>
</div>

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'
import { useLang } from '@/context/LangContext'
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)
}
// 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
const MONTH_OFF: Record<string, number> = {
فروردین: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">
{/* LEFT — calendar */}
<div dir={isFa ? 'rtl' : 'ltr'}>
<div id="calendar" dir={isFa ? 'rtl' : 'ltr'} style={{ scrollMarginTop: 90 }}>
<MiniCalendar anchor={calAnchor} onNav={handleCalNav} eventDates={eventDates}
onSelect={d => {
const jp = jParts(d)

View File

@ -343,7 +343,7 @@ export default function Experts() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.3 }}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'blur(10px)' }}
/>
</AnimatePresence>
</div>
@ -465,7 +465,7 @@ export default function Experts() {
width: '100%',
height: '100%',
objectFit: 'cover',
filter: (isActive || isHovered) ? 'none' : 'grayscale(100%)',
filter: 'blur(5px)',
transition: 'filter 0.3s ease'
}}
/>
@ -567,7 +567,7 @@ function ExpertCard({ name, role, photo, icon }: { name: string; role: string; p
width: '100%',
height: '100%',
objectFit: 'cover',
filter: hovered ? 'grayscale(0%)' : 'grayscale(100%)',
filter: 'blur(8px)',
transform: hovered ? 'scale(1.03)' : 'scale(1)',
transition: 'all 0.4s ease'
}}

View File

@ -17,7 +17,7 @@ type Glance = {
const ITEMS: Glance[] = [
{
source: 'Financial Times',
date: 'اردیبهشت ۱۴۰۵', dateEn: 'May 2026',
date: '۱۴ اردیبهشت ۱۴۰۵', dateEn: 'May 4, 2026',
title: 'شوک هیدروژنی به بازار جهانی؛ آغاز رسمی عصر فولادسازی بدون کربن',
titleEn: 'Hydrogen Shock to Global Markets; the Carbon-Free Steel Era Begins',
desc: 'پروژه عظیم «فولاد سبز H2» در سوئد با جذب بی‌سابقه ۶.۵ میلیارد یورو سرمایه، رسماً وارد فاز اجرایی شد تا نخستین کارخانه فولادسازی در مقیاس تجاری بر پایه هیدروژن سبز (بدون نیاز به زغال‌سنگ) در جهان متولد شود. این تأسیسات با هدف کاهش ۹۵ درصدی انتشار CO₂، قواعد رقابت در زنجیره ارزش اروپا را برای همیشه بازتعریف می‌کند.',
@ -26,7 +26,7 @@ const ITEMS: Glance[] = [
},
{
source: 'Reuters',
date: 'اردیبهشت ۱۴۰۵', dateEn: 'May 2026',
date: '۱۲ اردیبهشت ۱۴۰۵', dateEn: 'May 2, 2026',
title: 'مکانیسم تنظیم مرزهای کربنی اروپا وارد فاز اجرای کامل شد',
titleEn: 'EU Carbon Border Mechanism Enters Full Enforcement',
desc: 'با اجرای کامل CBAM، صادرکنندگان فولاد به اتحادیه اروپا اکنون موظف‌اند هزینه کامل کربن نهفته در محصولات خود را بپردازند. این تحول، فشار بی‌سابقه‌ای بر تولیدکنندگان مبتنی بر کوره بلند وارد کرده و مزیت رقابتی را به سمت فولاد کم‌کربن سوق می‌دهد.',
@ -35,7 +35,7 @@ const ITEMS: Glance[] = [
},
{
source: 'Bloomberg',
date: 'فروردین ۱۴۰۵', dateEn: 'Apr 2026',
date: '۳۰ فروردین ۱۴۰۵', dateEn: 'Apr 19, 2026',
title: 'هوش مصنوعی، تقاضای جهانی فولاد مراکز داده را بازتعریف می‌کند',
titleEn: 'AI Is Rewriting Global Steel Demand for Data Centers',
desc: 'موج ساخت مراکز داده هوش مصنوعی، تقاضای جدیدی برای مقاطع ساختمانی و فولاد ویژه ایجاد کرده است. تحلیلگران پیش‌بینی می‌کنند این روند تا پایان دهه، یکی از پیشران‌های اصلی رشد مصرف فولاد در اقتصادهای پیشرفته باشد.',

View File

@ -1,17 +1,17 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { FileText, ArrowLeft } from 'lucide-react'
import { FileText } from 'lucide-react'
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 GOLD = '#CD9E53'
const EASE = [0.22, 1, 0.36, 1] as const
type ScrollDir = 'left' | 'right' | 'up'
type Cover = { img: string; num: string; dir: ScrollDir; fileUrl?: string }
/* 5 monthly-magazine covers exported from the Figma design */
const COVERS: { img: string; num: string; dir: ScrollDir }[] = [
/* static fallback — Figma covers; replaced at runtime by panel factory-reports */
const COVERS: Cover[] = [
{ img: '/factory/cover-1.png', num: '۵۵', dir: 'right' },
{ img: '/factory/cover-2.png', num: '۵۴', dir: 'up' },
{ 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' },
]
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 }) {
const offset =
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 */
function ReportCard({ img, num, lang }: { img: string; num: string; lang: 'fa' | 'en' }) {
/* compact card — cover + navy download bar (direct file download) + issue-number badge */
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 (
<article
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%',
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 }}>
<img src={img} alt="" style={{ width: '100%', height: 184, objectFit: 'contain', display: 'block' }} />
</div>
<a
href={MSC_URL} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}
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} />
{lang === 'fa' ? 'دانلود ماهنامه' : 'Download'}
</span>
<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>
{fileUrl ? (
<a
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' }}
>
{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>}
</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>
)
}
export default function FactoryReportsScroll() {
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 (
<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>
</div>
{/* 5 cards + «view all» on the left */}
<div style={{ display: 'flex', alignItems: 'center', gap: 22 }} className="max-lg:!flex-col max-lg:!items-stretch max-lg:!gap-5">
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 14 }} className="max-lg:!grid-cols-3 max-sm:!grid-cols-2">
{COVERS.map((c, i) => (
// 5th+ cover is hidden on phones so the 2-col grid stays a clean 2×2 (no orphan)
<div key={c.img} className={i >= 4 ? 'max-sm:!hidden' : undefined}>
<ScrollAnimation direction={c.dir}>
<ReportCard img={c.img} num={c.num} lang={lang} />
</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 style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 14 }} className="max-lg:!grid-cols-3 max-sm:!grid-cols-2">
{covers.map((c, i) => (
// 5th+ cover is hidden on phones so the 2-col grid stays a clean 2×2 (no orphan)
<div key={c.img + i} className={i >= 4 ? 'max-sm:!hidden' : undefined}>
<ScrollAnimation direction={c.dir}>
<ReportCard img={c.img} num={c.num} fileUrl={c.fileUrl} lang={lang} />
</ScrollAnimation>
</div>
))}
</div>
</div>

View File

@ -197,7 +197,7 @@ function CommentCard({ c, lang }: { c: Post['comments'][number]; lang: 'fa' | 'e
function MembershipGate({ lang, onPreviewLogin }: { lang: 'fa' | 'en'; onPreviewLogin: () => void }) {
const isFa = lang === 'fa'
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.'
return (
<div

View File

@ -58,7 +58,7 @@ function Sources({ sources, lang }: { sources: { title: string; url: string }[];
function MembershipGate({ lang, onLogin }: { lang: 'fa' | 'en'; onLogin: () => void }) {
const isFa = lang === 'fa'
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.'
return (
<div style={{

View File

@ -309,6 +309,7 @@ await pool.query(`
date_en TEXT,
excerpt_fa TEXT,
excerpt_en TEXT,
file_url TEXT,
scroll_dir TEXT CHECK(scroll_dir IN ('left','right','up')) DEFAULT 'up',
sort_order INTEGER DEFAULT 0,
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 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);
}
@ -592,6 +594,7 @@ export function rowToFactoryReport(row) {
dateEn: row.date_en,
excerptFa: row.excerpt_fa,
excerptEn: row.excerpt_en,
fileUrl: row.file_url,
scrollDir: row.scroll_dir,
sortOrder: row.sort_order,
createdAt: row.created_at,

View File

@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nanoid": "^5.0.7",
"nodemailer": "^8.0.10",
"nodemailer": "^9.0.1",
"pg": "^8.13.1",
"sharp": "^0.35.1"
},
@ -2177,9 +2177,9 @@
}
},
"node_modules/nodemailer": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@ -2824,9 +2824,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"

View File

@ -24,7 +24,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nanoid": "^5.0.7",
"nodemailer": "^8.0.10",
"nodemailer": "^9.0.1",
"pg": "^8.13.1",
"sharp": "^0.35.1"
},

View File

@ -2,11 +2,11 @@ const $ = (sel, root = document) => root.querySelector(sel);
const root = $('#app');
const TAB_GROUPS = [
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'media', 'events', 'banners'] },
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'factoryReports', 'media', 'events', 'banners'] },
{ label: 'کاربران', tabs: ['members', 'newsletter'] },
];
const TAB_LABELS = {
glance: 'در یک نگاه', radarFuture: 'رادار آینده', media: 'ویدیوکست و پادکست',
glance: 'در یک نگاه', radarFuture: 'رادار آینده', factoryReports: 'ماهنامه کارخانه', media: 'ویدیوکست و پادکست',
events: 'رویدادها', banners: 'بنرها',
members: 'کاربران سایت', newsletter: 'خبرنامه',
};
@ -136,7 +136,7 @@ function renderLogin(errorMsg = '') {
<label>رمز عبور
<input name="password" type="password" autocomplete="current-password" required />
</label>
${errorMsg ? `<div class="error">${errorMsg}</div>` : ''}
${errorMsg ? `<div class="error">${escapeHtml(errorMsg)}</div>` : ''}
<button type="submit">ورود</button>
</form>
</div>
@ -184,6 +184,7 @@ function wireTabs() {
const t = btn.dataset.tab;
if (t === 'glance') renderRadarList();
else if (t === 'radarFuture') renderRadarFutureList();
else if (t === 'factoryReports') renderFactoryReportsList();
else if (t === 'media') renderMediaList();
else if (t === 'events') renderEventsList();
else if (t === 'banners') renderBannersList();
@ -1845,7 +1846,7 @@ async function renderFactoryReportsList() {
function renderFactoryReportEditor(report) {
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;
root.innerHTML = `
<header class="topbar">
@ -1866,13 +1867,45 @@ function renderFactoryReportEditor(report) {
</select>
</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="excerptEn" rows="3">${escapeHtml(r.excerptEn||'')}</textarea></label></div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
</form>
</main>`;
$('#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 => {
ev.preventDefault();
const fd = new FormData(ev.target);
@ -1882,6 +1915,7 @@ function renderFactoryReportEditor(report) {
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null,
dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null,
excerptFa: fd.get('excerptFa')||null, excerptEn: fd.get('excerptEn')||null,
fileUrl: fd.get('fileUrl')||null,
scrollDir: fd.get('scrollDir')||'up',
sortOrder: Number(fd.get('sortOrder')||0),
};

76
panel/seed-radar.js Normal file
View File

@ -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);

View File

@ -16,7 +16,7 @@ if (existing) {
process.exit(0);
}
const hash = bcrypt.hashSync(password, 10);
const hash = bcrypt.hashSync(password, 12);
const info = await db
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'owner') RETURNING id")
.run(username, hash);

View File

@ -14,7 +14,7 @@ import { nanoid } from 'nanoid';
import path from 'node:path';
import fs from 'node:fs';
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';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -55,8 +55,8 @@ const uploadRaw = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 200 * 1024 * 1024 },
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);
else cb(new Error('Only video or audio uploads are allowed'));
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, 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.
// 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) {
if (!buf || buf.length < 12) return null;
const b = buf;
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(8) === '89504e470d0a1a0a') return 'image'; // PNG
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
// 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.
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({
origin: (origin, cb) => {
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) => {
if (!req.file) return res.status(400).json({ error: 'no_file' });
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);
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 {
const ext = (req.file.originalname.match(/\.[a-z0-9]+$/i)?.[0] || '').toLowerCase();
const key = `${nanoid(12)}${ext}`;
@ -772,7 +775,7 @@ app.put('/api/radar-pages/:slug', authRequired, async (req, res) => {
// ---------- factory reports ----------
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) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
@ -794,6 +797,7 @@ function normalizeFactoryReportBody(b) {
title_en: b.titleEn || null,
date_fa: b.dateFa || null, date_en: b.dateEn || 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',
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' });
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,
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,
@excerpt_fa,@excerpt_en,@scroll_dir,@sort_order)
@excerpt_fa,@excerpt_en,@file_url,@scroll_dir,@sort_order)
`).run({ id, ...r });
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' });
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,
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 });
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) => {
const s = normalizeInstituteStatBody(req.body || {});
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)
VALUES (@id,@label_fa,@label_en,@value,@suffix_fa,@sort_order)
`).run({ id, ...s });
@ -959,7 +963,7 @@ function normalizeMarketPriceBody(b) {
app.post('/api/market-prices', authRequired, async (req, res) => {
const p = normalizeMarketPriceBody(req.body || {});
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)
VALUES (@id,@name,@value,@unit,@change,@change_percent,@trend,@sort_order)
`).run({ id, ...p });
@ -1021,7 +1025,7 @@ app.post('/api/market-chart-points', authRequired, async (req, res) => {
const c = normalizeChartPointBody(req.body || {});
if (!c.series) return res.status(400).json({ error: 'series_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)
VALUES (@id,@series,@label,@value,@meta,@sort_order)
`).run({ id, ...c });
@ -1333,7 +1337,7 @@ app.delete('/api/risks/:id', authRequired, async (req, res) => {
// ---------- users (admin management) ----------
// 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();
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' });
const exists = await db.prepare('SELECT 1 FROM users WHERE username = ?').get(username);
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 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 });
@ -1367,7 +1371,7 @@ app.put('/api/users/:id', authRequired, async (req, res) => {
}
if (password) {
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);
} else {
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));
});
// 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) {
return {
overline_fa: b.overlineFa || null,
@ -1488,9 +1501,9 @@ function normalizeBannerBody(b) {
subtitle_en: b.subtitleEn || null,
cta_label_fa: b.ctaLabelFa || null,
cta_label_en: b.ctaLabelEn || null,
cta_url: b.ctaUrl || null,
cta_url: safeHref(b.ctaUrl),
image: b.image || null,
position: b.position || 'hero',
position: BANNER_POSITIONS.includes(b.position) ? b.position : 'hero',
active: b.active !== false ? 1 : 0,
sort_order: Number(b.sortOrder) || 0,
};
@ -1533,7 +1546,9 @@ function escHtml(s) {
}
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' });
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: 'رمز عبور باید حداقل ۸ کاراکتر باشد' });
const exists = await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email);
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 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));
@ -1672,8 +1687,11 @@ app.get('/api/sso/statista', async (req, res) => {
// 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);
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({
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');
const sig = createHmac('sha256', SSO_SECRET).update(body).digest('base64url');
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);
if (!member) {
// 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')
.run('کاربر جدید', `${phone}@otp.fstt.ir`, hash, phone);
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 (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: 'این شماره موبایل قبلاً ثبت شده است' });
const hash = bcrypt.hashSync(password, 10);
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id')
.run(name.trim(), email.toLowerCase().trim(), hash, phone);
const hash = bcrypt.hashSync(password, 12);
let info;
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' });
res.cookie('member_session', token, memberCookieOpts(req));
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: 'رمز جدید باید حداقل ۸ کاراکتر باشد' });
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: 'کد نامعتبر یا منقضی است' });
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
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' });
@ -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: 'رمز جدید باید حداقل ۸ کاراکتر باشد' });
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: 'رمز فعلی اشتباه است' });
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 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));
@ -1880,6 +1905,12 @@ app.get('/api/members/me/purchases/:purchaseId/download', memberRequired, async
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, () => {
console.log(`Panel running at ${PUBLIC_ORIGIN}`);
});