Compare commits
1 Commits
main
...
42f2087b7c
| Author | SHA1 | Date | |
|---|---|---|---|
| 42f2087b7c |
390
package-lock.json
generated
390
package-lock.json
generated
@@ -8,14 +8,7 @@
|
||||
"name": "guilan-ace-frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.1",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
@@ -56,15 +49,12 @@
|
||||
"next": "^15.4.6",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-date-object": "^2.1.9",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^2.15.4",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -82,7 +72,6 @@
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
@@ -107,155 +96,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"version": "7.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz",
|
||||
"integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.3",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-markdown": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
|
||||
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.7.1",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/markdown": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/search/-/search-6.7.0.tgz",
|
||||
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.1",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/view/-/view-6.43.1.tgz",
|
||||
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
@@ -1064,79 +912,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/css/-/css-1.3.3.tgz",
|
||||
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/markdown": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/markdown/-/markdown-1.6.4.tgz",
|
||||
"integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.18",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@next/env/-/env-15.5.18.tgz",
|
||||
@@ -2888,12 +2663,6 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
|
||||
@@ -2925,16 +2694,6 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
"version": "15.5.13",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -3717,12 +3476,6 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4345,19 +4098,6 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/fault/-/fault-1.0.4.tgz",
|
||||
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"format": "^0.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
@@ -4442,14 +4182,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/format": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/format/-/format-0.2.2.tgz",
|
||||
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -4744,21 +4476,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/highlightjs-vue": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
|
||||
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -5158,20 +4875,6 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/lowlight/-/lowlight-1.20.0.tgz",
|
||||
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fault": "^1.0.0",
|
||||
"highlight.js": "~10.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
@@ -6564,15 +6267,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -6655,12 +6349,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||
@@ -6688,16 +6376,6 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-popper": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.61.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
||||
@@ -6746,20 +6424,6 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-multi-date-picker": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-date-object": "^2.1.8",
|
||||
"react-element-popper": "^2.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-qr-code": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
|
||||
@@ -6866,26 +6530,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.1",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
|
||||
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"highlight.js": "^10.4.1",
|
||||
"highlightjs-vue": "^1.0.0",
|
||||
"lowlight": "^1.17.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"refractor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.20.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
@@ -6955,22 +6599,6 @@
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/refractor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/refractor/-/refractor-5.0.0.tgz",
|
||||
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/prismjs": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"parse-entities": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
@@ -7394,12 +7022,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz",
|
||||
@@ -7961,12 +7583,6 @@
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
|
||||
11
package.json
11
package.json
@@ -10,14 +10,7 @@
|
||||
"start": "next start --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.1",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
@@ -58,15 +51,12 @@
|
||||
"next": "^15.4.6",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-date-object": "^2.1.9",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^2.15.4",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -84,7 +74,6 @@
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import AdminAuthorizations from "@/views/AdminAuthorizations";
|
||||
|
||||
export default function AdminAuthorizationsPage() {
|
||||
return <AdminAuthorizations />;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import AdminBlogAssets from "@/views/AdminBlogAssets";
|
||||
|
||||
type Params = Promise<{ id: string }>;
|
||||
|
||||
export default async function AdminBlogAssetsPage({ params }: { params: Params }) {
|
||||
const { id } = await params;
|
||||
return <AdminBlogAssets postId={Number(id)} />;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import AdminBlogEditor from "@/views/AdminBlogEditor";
|
||||
|
||||
type Params = Promise<{ id: string }>;
|
||||
|
||||
export default async function AdminBlogEditorPage({ params }: { params: Params }) {
|
||||
const { id } = await params;
|
||||
return <AdminBlogEditor postId={id === "new" ? null : Number(id)} />;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import AdminBlogPreview from "@/views/AdminBlogPreview";
|
||||
|
||||
type Params = Promise<{ id: string }>;
|
||||
|
||||
export default async function AdminBlogPreviewPage({ params }: { params: Params }) {
|
||||
const { id } = await params;
|
||||
return <AdminBlogPreview postId={Number(id)} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import AdminBlogCategories from "@/views/AdminBlogCategories";
|
||||
|
||||
export default function AdminBlogCategoriesPage() {
|
||||
return <AdminBlogCategories />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import AdminBlog from "@/views/AdminBlog";
|
||||
|
||||
export default function AdminBlogPage() {
|
||||
return <AdminBlog />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import AdminBlogTags from "@/views/AdminBlogTags";
|
||||
|
||||
export default function AdminBlogTagsPage() {
|
||||
return <AdminBlogTags />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import AdminCoupons from "@/views/AdminCoupons";
|
||||
|
||||
export default function AdminCouponsRoute() {
|
||||
return <AdminCoupons />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import AdminDashboard from "@/views/AdminDashboard";
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
return <AdminDashboard />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import AdminEventForm from "@/views/AdminEventForm";
|
||||
|
||||
export default function AdminEventCreateRoute() {
|
||||
return <AdminEventForm mode="create" />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import AdminMetaOptions from "@/views/AdminMetaOptions";
|
||||
|
||||
export default function AdminMajorsRoute() {
|
||||
return <AdminMetaOptions kind="majors" />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/dashboard");
|
||||
redirect("/admin/users");
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import AdminMetaOptions from "@/views/AdminMetaOptions";
|
||||
|
||||
export default function AdminUniversitiesRoute() {
|
||||
return <AdminMetaOptions kind="universities" />;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import GoogleAuthCallback from "@/views/GoogleAuthCallback";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ادامه ورود با گوگل",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function GoogleAuthCallbackPage() {
|
||||
return <GoogleAuthCallback />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { BlogDetailPageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <BlogDetailPageLoading />;
|
||||
}
|
||||
@@ -1,35 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CalendarDays, Clock3, Hash, ListTree } from "lucide-react";
|
||||
import BlogPostActions from "@/components/BlogPostActions";
|
||||
import BlogPostInteractions from "@/components/BlogPostInteractions";
|
||||
import BlogTableOfContents from "@/components/BlogTableOfContents";
|
||||
import BlogThumbnail from "@/components/BlogThumbnail";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Link } from "@/lib/router";
|
||||
import { blogPostPath, blogPostUrl, normalizeBlogSlugParam } from "@/lib/blog-routes";
|
||||
import { extractMarkdownHeadings } from "@/lib/markdown-headings";
|
||||
import { PublicApiError, getPublicPost, getRecommendedPosts } from "@/lib/public-api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PublicApiError, getPublicPost } from "@/lib/public-api";
|
||||
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalaliDate, getBlogCardImageUrl, getBlogHeroImageUrl, toPersianDigits } from "@/lib/utils";
|
||||
import { formatJalali } from "@/lib/utils";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
type Writer = NonNullable<Types.PostListSchema["writers"]>[number];
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function cleanText(value?: string | null) {
|
||||
if (!value) return "";
|
||||
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function personName(person: { first_name: string; last_name: string; username: string }) {
|
||||
return [person.first_name, person.last_name].filter(Boolean).join(" ") || person.username;
|
||||
}
|
||||
|
||||
async function loadPost(slug: string) {
|
||||
try {
|
||||
return await getPublicPost(slug);
|
||||
@@ -41,147 +27,28 @@ async function loadPost(slug: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecommended(slug: string) {
|
||||
try {
|
||||
return await getRecommendedPosts(slug, 3);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function Topics({ tags }: { tags: Types.PostListSchema["tags"] }) {
|
||||
if (!tags.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-start gap-2" aria-label="موضوعات نوشته">
|
||||
|
||||
{tags.map((tag) => (
|
||||
<Link key={tag.id} to={`/blog?tag=${encodeURIComponent(tag.slug)}`}>
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 transition bg-slate-200 dark:bg-slate-600 hover:border-primary hover:text-primary">
|
||||
<Hash className="h-3 w-3 text-primary" />
|
||||
{tag.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breadcrumbs({ post }: { post: Types.PostDetailSchema }) {
|
||||
const crumbs = post.category_path || [];
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground" aria-label="مسیر نوشته">
|
||||
<Link to="/blog" className="transition hover:text-primary">
|
||||
بلاگ
|
||||
</Link>
|
||||
{crumbs.map((category) => (
|
||||
<span key={category.id} className="flex items-center gap-2">
|
||||
<span>/</span>
|
||||
<Link to={`/blog?category=${encodeURIComponent(category.slug)}`} className="transition hover:text-primary">
|
||||
{category.name}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function WriterCards({ post }: { post: Types.PostDetailSchema }) {
|
||||
const writers = post.writers?.length ? post.writers : [post.author];
|
||||
|
||||
return (
|
||||
<section className="mt-8 rounded-[2rem] border border-border/70 bg-card/90 p-5 shadow-sm">
|
||||
{/* <div className="mb-4 text-right">
|
||||
<p className="text-sm font-medium text-primary">نویسندگان</p>
|
||||
</div> */}
|
||||
<h2 className="mb-4 text-2xl font-bold">درباره نویسندگان این مقاله</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{writers.map((writer: Writer) => {
|
||||
const image = writer.profile_picture_preview_url || writer.profile_picture_thumbnail_url || writer.profile_picture;
|
||||
return (
|
||||
<Link
|
||||
key={writer.id}
|
||||
to={`/blog?author=${encodeURIComponent(writer.username)}`}
|
||||
className="flex items-start gap-4 rounded-3xl border border-border/70 bg-background/80 p-4 text-right transition hover:-translate-y-0.5 hover:border-primary/50 hover:shadow-lg"
|
||||
>
|
||||
<Avatar className="h-14 w-14">
|
||||
<AvatarImage src={image || undefined} alt={personName(writer)} />
|
||||
<AvatarFallback>{personName(writer)[0] || "ن"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-bold">{personName(writer)}</h3>
|
||||
<p className="mt-2 line-clamp-4 text-sm leading-7 text-muted-foreground">
|
||||
{writer.bio || "توضیحی برای این نویسنده ثبت نشده است."}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendedPosts({ posts }: { posts: Types.PostListSchema[] }) {
|
||||
if (!posts.length) return null;
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[2rem] border border-border/70 bg-card/90 p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-2xl font-bold">مقالات پیشنهادی</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={blogPostPath(post.slug)}
|
||||
className="group overflow-hidden rounded-3xl border border-border/70 bg-background transition hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<BlogThumbnail
|
||||
post={post}
|
||||
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
|
||||
className="aspect-[16/10]"
|
||||
/>
|
||||
<div className="space-y-2 p-4 text-right">
|
||||
<h3 className="line-clamp-2 font-semibold leading-7 group-hover:text-primary">{post.title}</h3>
|
||||
<time className="text-xs text-muted-foreground" dateTime={post.published_at || post.created_at}>
|
||||
{formatJalaliDate(post.published_at || post.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(normalizeBlogSlugParam(slug));
|
||||
const post = await loadPost(slug);
|
||||
const description = cleanText(post.excerpt || post.content).slice(0, 160);
|
||||
const metaTitle = post.seo_title || post.og_title || post.title;
|
||||
const metaDescription = post.seo_description || post.og_description || description;
|
||||
const canonical = post.canonical_url || blogPostPath(post.slug);
|
||||
const image = toAbsoluteUrl(
|
||||
post.og_image_url || getBlogHeroImageUrl(post),
|
||||
post.absolute_featured_image_url || post.featured_image,
|
||||
apiBaseUrl,
|
||||
) ?? `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
alternates: { canonical },
|
||||
robots: post.noindex ? { index: false, follow: true } : undefined,
|
||||
title: post.title,
|
||||
description,
|
||||
alternates: { canonical: `/blog/${post.slug}` },
|
||||
openGraph: {
|
||||
title: post.og_title || metaTitle,
|
||||
description: post.og_description || metaDescription,
|
||||
url: blogPostUrl(siteUrl, post.slug),
|
||||
siteName: "انجمن علمی مهندسی کامپیوتر شرق گیلان",
|
||||
title: post.title,
|
||||
description,
|
||||
url: `${siteUrl}/blog/${post.slug}`,
|
||||
siteName: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
type: "article",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
@@ -190,8 +57,8 @@ export async function generateMetadata({
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.og_title || metaTitle,
|
||||
description: post.og_description || metaDescription,
|
||||
title: post.title,
|
||||
description,
|
||||
images: [image],
|
||||
},
|
||||
};
|
||||
@@ -203,126 +70,86 @@ export default async function BlogDetailPage({
|
||||
params: Params;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(normalizeBlogSlugParam(slug));
|
||||
const recommendedPosts = await loadRecommended(post.slug);
|
||||
const headings = extractMarkdownHeadings(post.content);
|
||||
const post = await loadPost(slug);
|
||||
const description = cleanText(post.excerpt || post.content).slice(0, 160);
|
||||
const metaDescription = post.seo_description || post.og_description || description;
|
||||
const coverImage = toAbsoluteUrl(getBlogHeroImageUrl(post), apiBaseUrl);
|
||||
const seoImage = toAbsoluteUrl(post.og_image_url || getBlogHeroImageUrl(post), apiBaseUrl) ?? `${siteUrl}/favicon.ico`;
|
||||
const writers = post.writers?.length ? post.writers : [post.author];
|
||||
const image = toAbsoluteUrl(
|
||||
post.absolute_featured_image_url || post.featured_image,
|
||||
apiBaseUrl,
|
||||
) ?? `${siteUrl}/favicon.ico`;
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: post.title,
|
||||
description: metaDescription,
|
||||
image: [seoImage],
|
||||
description,
|
||||
image: [image],
|
||||
datePublished: post.published_at || post.created_at,
|
||||
dateModified: post.updated_at,
|
||||
url: blogPostUrl(siteUrl, post.slug),
|
||||
author: writers.map((writer) => ({
|
||||
url: `${siteUrl}/blog/${post.slug}`,
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: personName(writer),
|
||||
})),
|
||||
name: [post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username,
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "انجمن علمی مهندسی کامپیوتر شرق گیلان",
|
||||
name: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: `${siteUrl}/favicon.ico`,
|
||||
},
|
||||
},
|
||||
keywords: post.tags.map((tag) => tag.name).join(", "),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.28))]" dir="rtl">
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="gap-8 xl:flex xl:items-start">
|
||||
<aside className="sticky top-24 max-h-[calc(100vh-7rem)] space-y-4 overflow-y-auto pr-1 hidden w-72 shrink-0 xl:block">
|
||||
<section className="rounded-[1.75rem] border border-border/70 bg-card/90 p-4 shadow-sm backdrop-blur">
|
||||
<h2 className="mb-3 flex items-center justify-start gap-2 text-right font-bold">
|
||||
<ListTree className="h-4 w-4 text-primary" />
|
||||
فهرست محتوا
|
||||
</h2>
|
||||
<BlogTableOfContents headings={headings} />
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<article className="overflow-hidden rounded-[2.5rem] border border-border/70 bg-card/95">
|
||||
<header className="space-y-6 p-5 text-right md:p-8">
|
||||
<Breadcrumbs post={post} />
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-black leading-[1.35] tracking-tight md:text-5xl md:leading-[1.45]">
|
||||
{post.title}
|
||||
</h1>
|
||||
{post.excerpt ? (
|
||||
<p className="text-base leading-8 text-muted-foreground md:text-lg">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1">
|
||||
<Clock3 className="h-4 w-4 text-primary" />
|
||||
{toPersianDigits(post.reading_time ?? 1)} دقیقه مطالعه
|
||||
</span>
|
||||
<time
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1"
|
||||
dateTime={post.published_at || post.created_at}
|
||||
>
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
{formatJalaliDate(post.published_at || post.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-5 md:px-8">
|
||||
<BlogThumbnail
|
||||
post={post}
|
||||
imageUrl={coverImage}
|
||||
className="aspect-[16/9] rounded-[2rem]"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-5 md:p-8 xl:hidden">
|
||||
<section className="rounded-[1.5rem] border border-border/70 bg-muted/20 p-4">
|
||||
<h2 className="mb-3 flex items-center justify-start gap-2 text-right font-bold">
|
||||
<ListTree className="h-4 w-4 text-primary" />
|
||||
فهرست محتوا
|
||||
</h2>
|
||||
<BlogTableOfContents headings={headings} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 px-5 pb-8 pt-6 md:px-8 md:pb-10">
|
||||
<Markdown content={post.content} justify size="base" className="mx-auto max-w-4xl" />
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Topics tags={post.tags} />
|
||||
</div>
|
||||
<BlogPostActions
|
||||
slug={post.slug}
|
||||
initialLikes={post.likes_count ?? 0}
|
||||
initialSaves={post.saves_count ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<WriterCards post={post} />
|
||||
<RecommendedPosts posts={recommendedPosts} />
|
||||
<BlogPostInteractions
|
||||
slug={post.slug}
|
||||
initialComments={post.comments_count ?? 0}
|
||||
/>
|
||||
</main>
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/blog">بازگشت به وبلاگ</Link>
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatJalali(post.published_at || post.created_at, false)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{image && (
|
||||
<div className="w-full aspect-video overflow-hidden rounded-t-lg bg-muted">
|
||||
<img
|
||||
src={image}
|
||||
alt={post.title}
|
||||
className="h-full w-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.category?.name ? <Badge variant="secondary">{post.category.name}</Badge> : null}
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag.id} variant="outline">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<CardTitle className="text-3xl leading-relaxed">{post.title}</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
نویسنده: {[post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{post.excerpt ? (
|
||||
<p className="rounded-lg border bg-muted/30 p-4 text-sm leading-7 text-muted-foreground">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
<Markdown content={post.content} justify size="base" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { BlogListingPageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <BlogListingPageLoading />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import Blog from "@/views/Blog";
|
||||
import { getBlogBanners, getBlogFilters, getPublicPosts } from "@/lib/public-api";
|
||||
import { getPublicPosts } from "@/lib/public-api";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
@@ -9,11 +9,6 @@ function firstString(value?: string | string[]) {
|
||||
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
|
||||
}
|
||||
|
||||
function stringList(value?: string | string[]) {
|
||||
const values = Array.isArray(value) ? value : value ? [value] : [];
|
||||
return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -50,24 +45,7 @@ export default async function BlogPage({
|
||||
}) {
|
||||
const resolved = await searchParams;
|
||||
const search = firstString(resolved.search).trim();
|
||||
const category = firstString(resolved.category).trim();
|
||||
const tags = stringList(resolved.tag);
|
||||
const authors = stringList(resolved.author);
|
||||
const [posts, banners, filters] = await Promise.all([
|
||||
getPublicPosts({ search: search || undefined, category: category || undefined, tag: tags, author: authors }),
|
||||
getBlogBanners().catch(() => []),
|
||||
getBlogFilters().catch(() => ({ categories: [], tags: [], authors: [] })),
|
||||
]);
|
||||
const posts = await getPublicPosts({ search: search || undefined });
|
||||
|
||||
return (
|
||||
<Blog
|
||||
initialPosts={posts}
|
||||
initialSearch={search}
|
||||
initialCategory={category}
|
||||
initialTags={tags}
|
||||
initialAuthors={authors}
|
||||
banners={banners}
|
||||
filters={filters}
|
||||
/>
|
||||
);
|
||||
return <Blog initialPosts={posts} initialSearch={search} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { DetailPageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <DetailPageLoading />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import EventDetail from "@/views/EventDetail";
|
||||
import { PublicApiError, getPublicEventBySlug } from "@/lib/public-api";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import { getEventSeoImageUrl } from "@/lib/utils";
|
||||
import { getThumbUrl } from "@/lib/utils";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function generateMetadata({
|
||||
const { slug } = await params;
|
||||
const event = await loadEvent(slug);
|
||||
const description = cleanText(event.description).slice(0, 160);
|
||||
const image = getEventSeoImageUrl(event) || `${siteUrl}/favicon.ico`;
|
||||
const image = event.absolute_featured_image_url || getThumbUrl(event) || `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title: event.title,
|
||||
@@ -84,7 +84,7 @@ export default async function EventDetailPage({
|
||||
: event.event_type === "on_site"
|
||||
? "https://schema.org/OfflineEventAttendanceMode"
|
||||
: "https://schema.org/MixedEventAttendanceMode",
|
||||
image: [getEventSeoImageUrl(event) || `${siteUrl}/favicon.ico`],
|
||||
image: [event.absolute_featured_image_url || getThumbUrl(event) || `${siteUrl}/favicon.ico`],
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
organizer: {
|
||||
"@type": "Organization",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ListingPageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <ListingPageLoading />;
|
||||
}
|
||||
Binary file not shown.
@@ -1,22 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { Suspense } from "react";
|
||||
import MobileBottomNav from "@/components/MobileBottomNav";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Footer from "@/components/Footer";
|
||||
import Providers from "@/components/providers";
|
||||
import RouteProgress from "@/components/RouteProgress";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import "../index.css";
|
||||
|
||||
const vazirmatn = localFont({
|
||||
src: "./fonts/Vazirmatn-variable.woff2",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
variable: "--font-vazirmatn",
|
||||
fallback: ["Tahoma", "Arial", "sans-serif"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
@@ -34,17 +22,11 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
||||
<body className={`${vazirmatn.variable} font-sans antialiased`}>
|
||||
<body>
|
||||
<Providers>
|
||||
<Suspense fallback={null}>
|
||||
<RouteProgress />
|
||||
</Suspense>
|
||||
<Navbar />
|
||||
<div className="min-h-screen pb-28 md:pb-0">
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
<MobileBottomNav />
|
||||
{children}
|
||||
<Footer />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "@/lib/router";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
@@ -12,7 +12,7 @@ export default function NotFound() {
|
||||
آدرسی که وارد کردهاید وجود ندارد یا جابهجا شده است.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link to="/">بازگشت به خانه</Link>
|
||||
<Link href="/">بازگشت به خانه</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { blogPostUrl } from "@/lib/blog-routes";
|
||||
import { getPublicEvents, getPublicPosts } from "@/lib/public-api";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
@@ -35,7 +34,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
|
||||
routes.push(
|
||||
...posts.map((post) => ({
|
||||
url: blogPostUrl(siteUrl, post.slug),
|
||||
url: `${siteUrl}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.published_at || post.created_at),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.7,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import DateObject from "react-date-object";
|
||||
import persian from "react-date-object/calendars/persian";
|
||||
import persian_fa from "react-date-object/locales/persian_fa";
|
||||
import DatePicker from "react-multi-date-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type AdminDateTimeFieldProps = {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function splitDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return { date: null as DateObject | null, time: "" };
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return { date: null, time: "" };
|
||||
}
|
||||
return {
|
||||
date: new DateObject({ date, calendar: persian, locale: persian_fa }),
|
||||
time: `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
function combineDateTime(date: DateObject | null, time: string) {
|
||||
if (!date || !time || !/^\d{2}:\d{2}$/.test(time)) return null;
|
||||
const gregorian = date.toDate();
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
gregorian.setHours(hours, minutes, 0, 0);
|
||||
return gregorian.toISOString();
|
||||
}
|
||||
|
||||
export default function AdminDateTimeField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
disabled,
|
||||
}: AdminDateTimeFieldProps) {
|
||||
const initial = React.useMemo(() => splitDateTime(value), [value]);
|
||||
const [date, setDate] = React.useState<DateObject | null>(initial.date);
|
||||
const [time, setTime] = React.useState(initial.time);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDate(initial.date);
|
||||
setTime(initial.time);
|
||||
}, [initial.date, initial.time]);
|
||||
|
||||
const emitChange = (nextDate: DateObject | null, nextTime: string) => {
|
||||
onChange(combineDateTime(nextDate, nextTime));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{label}
|
||||
{required ? <span className="text-destructive"> *</span> : null}
|
||||
</Label>
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_120px]">
|
||||
<DatePicker
|
||||
value={date}
|
||||
onChange={(next) => {
|
||||
const nextDate = next instanceof DateObject ? next : null;
|
||||
setDate(nextDate);
|
||||
emitChange(nextDate, time);
|
||||
}}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
calendarPosition="bottom-right"
|
||||
disabled={disabled}
|
||||
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="تاریخ"
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
<Input
|
||||
dir="ltr"
|
||||
type="time"
|
||||
value={time}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
setTime(event.target.value);
|
||||
emitChange(date, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type AsyncComboboxOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type AsyncSearchableComboboxProps = {
|
||||
value?: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
loadOptions: (params: { search: string; limit: number; offset: number }) => Promise<{
|
||||
count: number;
|
||||
results: AsyncComboboxOption[];
|
||||
}>;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
allowClear?: boolean;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AsyncSearchableCombobox({
|
||||
value,
|
||||
onChange,
|
||||
loadOptions,
|
||||
placeholder = "انتخاب کنید",
|
||||
searchPlaceholder = "جستجو...",
|
||||
emptyText = "موردی پیدا نشد.",
|
||||
disabled = false,
|
||||
allowClear = true,
|
||||
pageSize = 20,
|
||||
className,
|
||||
}: AsyncSearchableComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
const [options, setOptions] = React.useState<AsyncComboboxOption[]>([]);
|
||||
const [count, setCount] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const selected = React.useMemo(
|
||||
() => options.find((option) => option.value === value),
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const fetchPage = React.useCallback(
|
||||
async (offset: number, append = false) => {
|
||||
if (append) setLoadingMore(true);
|
||||
else setLoading(true);
|
||||
try {
|
||||
const data = await loadOptions({ search: debouncedSearch, limit: pageSize, offset });
|
||||
setCount(data.count);
|
||||
setOptions((current) => {
|
||||
const next = append ? [...current, ...data.results] : data.results;
|
||||
const byValue = new Map(next.map((option) => [option.value, option]));
|
||||
return Array.from(byValue.values());
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[debouncedSearch, loadOptions, pageSize],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void fetchPage(0);
|
||||
}, [fetchPage, open]);
|
||||
|
||||
const hasMore = options.length < count;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between gap-2", className)}
|
||||
>
|
||||
<span className="truncate text-right">{selected?.label || value || placeholder}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" dir="rtl">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput value={search} onValueChange={setSearch} placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
در حال جستجو...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allowClear ? (
|
||||
<CommandItem
|
||||
value="__clear"
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("ml-2 h-4 w-4", !value ? "opacity-100" : "opacity-0")} />
|
||||
همه موارد
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => {
|
||||
onChange(option.value === value ? null : option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-right">
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Check className={cn("h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{hasMore ? (
|
||||
<div className="p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={loadingMore}
|
||||
onClick={() => void fetchPage(options.length, true)}
|
||||
>
|
||||
{loadingMore ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
|
||||
بارگذاری بیشتر
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bookmark, Heart, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { cn, toPersianDigits } from "@/lib/utils";
|
||||
|
||||
type BlogPostActionsProps = {
|
||||
slug: string;
|
||||
initialLikes: number;
|
||||
initialSaves: number;
|
||||
};
|
||||
|
||||
export default function BlogPostActions({
|
||||
slug,
|
||||
initialLikes,
|
||||
initialSaves,
|
||||
}: BlogPostActionsProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [loadingAction, setLoadingAction] = useState<"like" | "save" | null>(null);
|
||||
const [interaction, setInteraction] = useState<Types.BlogInteractionSchema>({
|
||||
liked: false,
|
||||
saved: false,
|
||||
likes_count: initialLikes,
|
||||
saves_count: initialSaves,
|
||||
comments_count: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
api.getBlogInteraction(slug)
|
||||
.then((data) => {
|
||||
if (mounted) setInteraction(data);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [isAuthenticated, slug]);
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (!isAuthenticated || loadingAction) return;
|
||||
setLoadingAction("like");
|
||||
try {
|
||||
setInteraction(await api.toggleLike(slug));
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSave = async () => {
|
||||
if (!isAuthenticated || loadingAction) return;
|
||||
setLoadingAction("save");
|
||||
try {
|
||||
setInteraction(await api.toggleSave(slug));
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 border-t border-border/70 pt-6" dir="rtl">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={toggleLike}
|
||||
disabled={!isAuthenticated || Boolean(loadingAction)}
|
||||
className="gap-2 rounded-full border border-border/60 bg-background/80 px-5 shadow-sm backdrop-blur hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/30"
|
||||
aria-label="پسندیدن نوشته"
|
||||
>
|
||||
{loadingAction === "like" ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Heart
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
interaction.liked && "fill-rose-500 text-rose-500",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span>{toPersianDigits(interaction.likes_count)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSave}
|
||||
disabled={!isAuthenticated || Boolean(loadingAction)}
|
||||
className="rounded-full border border-border/60 bg-background/80 shadow-sm backdrop-blur hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-950/30"
|
||||
aria-label="ذخیره نوشته"
|
||||
>
|
||||
{loadingAction === "save" ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Bookmark
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
interaction.saved && "fill-amber-500 text-amber-500",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{!isAuthenticated ? (
|
||||
<span className="basis-full text-center text-xs text-muted-foreground">
|
||||
برای پسندیدن یا ذخیره کردن وارد حساب کاربری شوید.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
Edit3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Reply,
|
||||
Send,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn, formatJalaliDate, resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
initialComments: number;
|
||||
};
|
||||
|
||||
type FlattenedReply = {
|
||||
comment: Types.CommentSchema;
|
||||
replyTo: Types.CommentSchema;
|
||||
};
|
||||
|
||||
function displayName(author: Types.CommentSchema["author"]) {
|
||||
return [author.first_name, author.last_name].filter(Boolean).join(" ") || author.username;
|
||||
}
|
||||
|
||||
function avatarInitial(author: Types.CommentSchema["author"]) {
|
||||
return displayName(author).trim()[0] || "ک";
|
||||
}
|
||||
|
||||
function visibleCommentCount(comments: Types.CommentSchema[]) {
|
||||
return comments.reduce((total, comment) => {
|
||||
if (comment.is_deleted || comment.is_hidden) return total;
|
||||
return total + 1 + visibleCommentCount(comment.replies || []);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function allDescendants(comment: Types.CommentSchema): Types.CommentSchema[] {
|
||||
return (comment.replies || []).flatMap((reply) => [reply, ...allDescendants(reply)]);
|
||||
}
|
||||
|
||||
function flattenReplies(comment: Types.CommentSchema): FlattenedReply[] {
|
||||
const replies: FlattenedReply[] = [];
|
||||
|
||||
const walk = (items: Types.CommentSchema[] | undefined, parent: Types.CommentSchema) => {
|
||||
(items || []).forEach((reply) => {
|
||||
replies.push({ comment: reply, replyTo: parent });
|
||||
walk(reply.replies, reply);
|
||||
});
|
||||
};
|
||||
|
||||
walk(comment.replies, comment);
|
||||
return replies;
|
||||
}
|
||||
|
||||
function findComment(comments: Types.CommentSchema[], id: number | null): Types.CommentSchema | undefined {
|
||||
if (!id) return undefined;
|
||||
for (const comment of comments) {
|
||||
if (comment.id === id) return comment;
|
||||
const reply = findComment(comment.replies || [], id);
|
||||
if (reply) return reply;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function BlogPostInteractions({
|
||||
slug,
|
||||
initialComments,
|
||||
}: Props) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [comments, setComments] = useState<Types.CommentSchema[]>([]);
|
||||
const [commentCount, setCommentCount] = useState(initialComments);
|
||||
const [content, setContent] = useState("");
|
||||
const [replyTo, setReplyTo] = useState<number | null>(null);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Types.CommentSchema | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [moderatingId, setModeratingId] = useState<number | null>(null);
|
||||
const [highlightedCommentId, setHighlightedCommentId] = useState<number | null>(null);
|
||||
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const canModerate = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
||||
|
||||
const replyTarget = useMemo(
|
||||
() => findComment(comments, replyTo),
|
||||
[comments, replyTo],
|
||||
);
|
||||
|
||||
const loadComments = async () => {
|
||||
const data = await api.getComments(slug);
|
||||
setComments(data);
|
||||
setCommentCount(visibleCommentCount(data));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
api.getComments(slug)
|
||||
.then((data) => {
|
||||
if (!mounted) return;
|
||||
setComments(data);
|
||||
setCommentCount(visibleCommentCount(data));
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (highlightTimeoutRef.current) {
|
||||
clearTimeout(highlightTimeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToComment = (commentId: number) => {
|
||||
const element = document.getElementById(`blog-comment-${commentId}`);
|
||||
if (!element) return;
|
||||
|
||||
if (highlightTimeoutRef.current) {
|
||||
clearTimeout(highlightTimeoutRef.current);
|
||||
}
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setHighlightedCommentId(commentId);
|
||||
highlightTimeoutRef.current = setTimeout(() => {
|
||||
setHighlightedCommentId(null);
|
||||
highlightTimeoutRef.current = null;
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await api.createComment(slug, { content: trimmed, parent_id: replyTo ?? undefined });
|
||||
setContent("");
|
||||
setReplyTo(null);
|
||||
await loadComments();
|
||||
toast({ title: "کامنت ثبت شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "ثبت کامنت ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hideComment = async (commentId: number) => {
|
||||
try {
|
||||
setModeratingId(commentId);
|
||||
await api.hideComment(commentId);
|
||||
await loadComments();
|
||||
toast({ title: "کامنت مخفی شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "مخفی کردن کامنت ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setModeratingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const unhideComment = async (commentId: number) => {
|
||||
try {
|
||||
setModeratingId(commentId);
|
||||
await api.unhideComment(commentId);
|
||||
await loadComments();
|
||||
toast({ title: "کامنت دوباره نمایش داده شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "نمایش دوباره کامنت ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setModeratingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteComment = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
setModeratingId(deleteTarget.id);
|
||||
await api.deleteComment(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
await loadComments();
|
||||
toast({ title: "کامنت حذف شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "حذف کامنت ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setModeratingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (comment: Types.CommentSchema) => {
|
||||
setEditingId(comment.id);
|
||||
setEditContent(comment.content);
|
||||
setReplyTo(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditContent("");
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
const trimmed = editContent.trim();
|
||||
if (!editingId || !trimmed) return;
|
||||
try {
|
||||
setSavingEdit(true);
|
||||
await api.updateComment(editingId, { content: trimmed });
|
||||
await loadComments();
|
||||
cancelEdit();
|
||||
toast({ title: "کامنت ویرایش شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "ویرایش کامنت ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderComment = (
|
||||
comment: Types.CommentSchema,
|
||||
options: {
|
||||
parentHidden?: boolean;
|
||||
replyToComment?: Types.CommentSchema;
|
||||
topLevelParent?: Types.CommentSchema;
|
||||
} = {},
|
||||
) => {
|
||||
const isOwnComment = Boolean(user?.id === comment.author.id);
|
||||
const isEditing = editingId === comment.id;
|
||||
const { parentHidden = false, replyToComment, topLevelParent } = options;
|
||||
const hidden = parentHidden || Boolean(comment.is_hidden);
|
||||
const isReply = Boolean(replyToComment);
|
||||
const flattenedReplies = isReply ? [] : flattenReplies(comment);
|
||||
const showReplyContext = Boolean(
|
||||
replyToComment && topLevelParent && replyToComment.id !== topLevelParent.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`blog-comment-${comment.id}`}
|
||||
key={comment.id}
|
||||
className={cn(
|
||||
"relative scroll-mt-28 rounded-3xl border p-4 shadow-sm transition-colors duration-300 border-border/70 bg-muted/20",
|
||||
hidden && "border-amber-400/40 bg-amber-50/50 dark:bg-amber-950/20",
|
||||
highlightedCommentId === comment.id && "border-primary bg-primary/10 shadow-lg shadow-primary/20 ring-2 ring-primary/30",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-start gap-3 text-right", hidden && "opacity-75")}>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/12 text-base font-black text-primary shadow-inner">
|
||||
{avatarInitial(comment.author)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-start gap-2 text-sm">
|
||||
<span className="font-semibold">{displayName(comment.author)}</span>
|
||||
<time className="text-xs text-muted-foreground" dateTime={comment.created_at}>
|
||||
{formatJalaliDate(comment.created_at)}
|
||||
</time>
|
||||
{showReplyContext && replyToComment ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary transition hover:bg-primary/20 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onClick={() => scrollToComment(replyToComment.id)}
|
||||
>
|
||||
در پاسخ به {displayName(replyToComment.author)}
|
||||
</button>
|
||||
) : null}
|
||||
{hidden ? (
|
||||
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-600 dark:text-amber-300">
|
||||
مخفی شده
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={editContent}
|
||||
onChange={(event) => setEditContent(event.target.value)}
|
||||
className="min-h-24 rounded-2xl bg-background text-right"
|
||||
/>
|
||||
<div className="flex flex-wrap justify-start gap-2">
|
||||
<Button type="button" size="sm" variant="ghost" className="gap-1 rounded-full" onClick={cancelEdit}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
لغو
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="gap-1 rounded-full" onClick={saveEdit} disabled={savingEdit || !editContent.trim()}>
|
||||
{savingEdit ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-sm leading-7">{comment.content}</p>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-start gap-2 text-xs text-muted-foreground">
|
||||
{isAuthenticated && !hidden ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 rounded-full px-3 text-xs"
|
||||
onClick={() => setReplyTo(comment.id)}
|
||||
>
|
||||
<Reply className="h-3.5 w-3.5" />
|
||||
پاسخ
|
||||
</Button>
|
||||
) : null}
|
||||
{isOwnComment && !hidden ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 rounded-full px-3 text-xs"
|
||||
onClick={() => startEdit(comment)}
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
ویرایش
|
||||
</Button>
|
||||
) : null}
|
||||
{canModerate ? (
|
||||
<>
|
||||
{hidden ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 rounded-full px-3 text-xs text-primary"
|
||||
onClick={() => unhideComment(comment.id)}
|
||||
disabled={moderatingId === comment.id}
|
||||
>
|
||||
{moderatingId === comment.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
نمایش مجدد
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 rounded-full px-3 text-xs"
|
||||
onClick={() => hideComment(comment.id)}
|
||||
disabled={moderatingId === comment.id}
|
||||
>
|
||||
{moderatingId === comment.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
مخفی کردن
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 rounded-full px-3 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(comment)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
حذف
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{flattenedReplies.length ? (
|
||||
<div className="mt-4 space-y-3 border-r border-primary/15 pr-4">
|
||||
{flattenedReplies.map(({ comment: reply, replyTo }) => (
|
||||
renderComment(reply, {
|
||||
parentHidden: hidden,
|
||||
replyToComment: replyTo,
|
||||
topLevelParent: comment,
|
||||
})
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const deleteReplyCount = deleteTarget ? allDescendants(deleteTarget).length : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mt-10 overflow-hidden rounded-[2rem] border border-border/70 bg-card/90 shadow-sm" dir="rtl">
|
||||
<CardHeader className="border-b border-border/60 bg-muted/20 text-right">
|
||||
<CardTitle className="flex items-center justify-start gap-2 text-2xl">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
کامنتها
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{toPersianDigits(commentCount)} کامنت برای این نوشته ثبت شده است.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 p-4 md:p-6">
|
||||
{!isAuthenticated ? (
|
||||
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/20 p-5 text-center text-sm leading-7 text-muted-foreground">
|
||||
برای ثبت کامنت باید وارد حساب کاربری شوید.
|
||||
<Button asChild className="mr-3" size="sm">
|
||||
<Link to="/auth">ورود / ثبتنام</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-3xl border border-border/70 bg-muted/20 p-4">
|
||||
{replyTarget ? (
|
||||
<div className="mb-3 flex items-center justify-between rounded-2xl bg-background px-3 py-2 text-sm">
|
||||
<Button variant="ghost" size="sm" onClick={() => setReplyTo(null)}>
|
||||
لغو پاسخ
|
||||
</Button>
|
||||
<span>پاسخ به {displayName(replyTarget.author)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="کامنت خود را بنویسید..."
|
||||
className="min-h-32 rounded-2xl bg-background text-right"
|
||||
/>
|
||||
<div className="mt-3 flex justify-start">
|
||||
<Button onClick={submitComment} disabled={submitting || !content.trim()} className="gap-2 rounded-full">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
ثبت کامنت
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : comments.length ? (
|
||||
<div className="space-y-5">{comments.map((comment) => renderComment(comment))}</div>
|
||||
) : (
|
||||
<div className="rounded-3xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
هنوز کامنتی ثبت نشده است.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent dir="rtl" className="text-right">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>حذف کامنت</AlertDialogTitle>
|
||||
<AlertDialogDescription className="leading-7">
|
||||
این عملیات کامنت را بهصورت نرم حذف میکند و دیگر در سایت نمایش داده نمیشود.
|
||||
{deleteReplyCount ? (
|
||||
<span className="mt-2 block font-medium text-destructive">
|
||||
{toPersianDigits(deleteReplyCount)} پاسخ وابسته به این کامنت هم حذف میشود.
|
||||
</span>
|
||||
) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:justify-start">
|
||||
<AlertDialogCancel>انصراف</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={deleteComment}
|
||||
>
|
||||
حذف کامنت
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MarkdownHeading } from "@/lib/markdown-headings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
headings: MarkdownHeading[];
|
||||
};
|
||||
|
||||
function getParentHeading(headings: MarkdownHeading[], index: number) {
|
||||
const heading = headings[index];
|
||||
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
||||
if (headings[cursor].level < heading.level) {
|
||||
return headings[cursor];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function BlogTableOfContents({ headings }: Props) {
|
||||
const [activeId, setActiveId] = useState(headings[0]?.id ?? "");
|
||||
|
||||
const parentById = new Map<string, string | null>();
|
||||
headings.forEach((heading, index) => {
|
||||
parentById.set(heading.id, getParentHeading(headings, index)?.id ?? null);
|
||||
});
|
||||
|
||||
const activeBranch = new Set<string>();
|
||||
let cursor = activeId;
|
||||
while (cursor) {
|
||||
activeBranch.add(cursor);
|
||||
cursor = parentById.get(cursor) ?? "";
|
||||
}
|
||||
|
||||
const visibleHeadings = headings.filter((heading) => {
|
||||
const parentId = parentById.get(heading.id);
|
||||
if (!parentId) return true;
|
||||
return activeBranch.has(parentId);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!headings.length) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
||||
if (visible?.target.id) {
|
||||
setActiveId(visible.target.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: "-20% 0px -65% 0px",
|
||||
threshold: [0, 1],
|
||||
},
|
||||
);
|
||||
|
||||
headings.forEach((heading) => {
|
||||
const element = document.getElementById(heading.id);
|
||||
if (element) observer.observe(element);
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [headings]);
|
||||
|
||||
if (!headings.length) {
|
||||
return <p className="text-sm leading-7 text-muted-foreground">برای این نوشته فهرست محتوا ثبت نشده است.</p>;
|
||||
}
|
||||
|
||||
const scrollToHeading = (id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
window.history.replaceState(null, "", `#${id}`);
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="space-y-1 text-sm">
|
||||
{visibleHeadings.map((heading) => {
|
||||
const active = activeId === heading.id;
|
||||
return (
|
||||
<button
|
||||
key={heading.id}
|
||||
type="button"
|
||||
onClick={() => scrollToHeading(heading.id)}
|
||||
className={cn(
|
||||
"block w-full rounded-2xl px-3 py-2 text-right leading-6 transition",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-primary/10 hover:text-primary",
|
||||
)}
|
||||
style={{ paddingRight: `${(heading.level - 1) * 0.85 + 0.75}rem` }}
|
||||
>
|
||||
{heading.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type * as Types from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BlogThumbnailProps = {
|
||||
post: Pick<Types.PostListSchema, "title" | "category" | "absolute_featured_image_thumbnail_url" | "absolute_featured_image_preview_url" | "absolute_featured_image_url" | "featured_image">;
|
||||
imageUrl?: string | null;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
function initials(title: string) {
|
||||
return title
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("");
|
||||
}
|
||||
|
||||
export default function BlogThumbnail({
|
||||
post,
|
||||
imageUrl,
|
||||
className,
|
||||
imageClassName,
|
||||
priority = false,
|
||||
}: BlogThumbnailProps) {
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<div className={cn("overflow-hidden bg-muted", className)}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={post.title}
|
||||
className={cn("h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]", imageClassName)}
|
||||
loading={priority ? "eager" : "lazy"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden bg-[radial-gradient(circle_at_20%_20%,rgba(34,197,94,0.28),transparent_32%),linear-gradient(135deg,#0f3d2e,#163f59_52%,#111827)] text-white",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(45deg,rgba(255,255,255,0.08)_25%,transparent_25%,transparent_50%,rgba(255,255,255,0.08)_50%,rgba(255,255,255,0.08)_75%,transparent_75%,transparent)] bg-[length:28px_28px] opacity-25" />
|
||||
<div className="relative flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<span className="rounded-full border border-white/25 bg-white/15 px-3 py-1 text-xs backdrop-blur">
|
||||
{post.category?.name || "بلاگ"}
|
||||
</span>
|
||||
<span className="text-5xl font-black tracking-tight">{initials(post.title) || "گـ"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
type ConfirmActionProps = {
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => unknown | Promise<unknown>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function ConfirmAction({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "حذف",
|
||||
cancelLabel = "انصراف",
|
||||
onConfirm,
|
||||
disabled = false,
|
||||
}: ConfirmActionProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
{trigger}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent dir="rtl">
|
||||
<AlertDialogHeader className="text-right">
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="leading-7">{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:justify-start">
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={disabled}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type GalleryLightboxItem = {
|
||||
id: number | string;
|
||||
alt: string;
|
||||
title?: string | null;
|
||||
previewSrc?: string | null;
|
||||
blurSrc?: string | null;
|
||||
fullSrc?: string | null;
|
||||
};
|
||||
|
||||
type GalleryLightboxProps = {
|
||||
items: GalleryLightboxItem[];
|
||||
open: boolean;
|
||||
initialIndex: number;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export default function GalleryLightbox({
|
||||
items,
|
||||
open,
|
||||
initialIndex,
|
||||
onOpenChange,
|
||||
}: GalleryLightboxProps) {
|
||||
const [currentIndex, setCurrentIndex] = React.useState(initialIndex);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setCurrentIndex(initialIndex);
|
||||
}
|
||||
}, [initialIndex, open]);
|
||||
|
||||
const currentItem = items[currentIndex];
|
||||
|
||||
const goToPrevious = React.useCallback(() => {
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
setCurrentIndex((current) => (current - 1 + items.length) % items.length);
|
||||
}, [items.length]);
|
||||
|
||||
const goToNext = React.useCallback(() => {
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
setCurrentIndex((current) => (current + 1) % items.length);
|
||||
}, [items.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
goToPrevious();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
goToNext();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [goToNext, goToPrevious, onOpenChange, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const neighborIndexes = [
|
||||
(currentIndex - 1 + items.length) % items.length,
|
||||
(currentIndex + 1) % items.length,
|
||||
];
|
||||
|
||||
neighborIndexes.forEach((index) => {
|
||||
const src = items[index]?.fullSrc || items[index]?.previewSrc;
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
const image = new window.Image();
|
||||
image.src = src;
|
||||
});
|
||||
}, [currentIndex, items, open]);
|
||||
|
||||
if (!currentItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="w-[min(96vw,1100px)] max-w-none overflow-hidden border-none bg-transparent p-0 shadow-none"
|
||||
dir="rtl"
|
||||
>
|
||||
<DialogTitle className="sr-only">{currentItem.title || currentItem.alt}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
پیشنمایش تصویر {currentIndex + 1} از {items.length}
|
||||
</DialogDescription>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-black/90 text-white shadow-2xl">
|
||||
<div className="relative min-h-[70vh]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute inset-y-0 left-0 z-10 w-1/4 cursor-w-resize bg-transparent"
|
||||
onClick={goToPrevious}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute inset-y-0 right-0 z-10 w-1/4 cursor-e-resize bg-transparent"
|
||||
onClick={goToNext}
|
||||
/>
|
||||
|
||||
<div className="absolute left-4 top-4 z-20 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="border-white/10 bg-black/45 text-white hover:bg-black/70"
|
||||
onClick={goToNext}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="border-white/10 bg-black/45 text-white hover:bg-black/70"
|
||||
onClick={goToPrevious}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ProgressiveImage
|
||||
src={currentItem.fullSrc || currentItem.previewSrc}
|
||||
blurSrc={currentItem.blurSrc || currentItem.previewSrc}
|
||||
alt={currentItem.alt}
|
||||
loading="eager"
|
||||
wrapperClassName="min-h-[70vh]"
|
||||
className="max-h-[80vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-white/10 bg-black/75 px-5 py-4 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{currentItem.title || currentItem.alt}</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-white/70">
|
||||
{currentIndex + 1} / {items.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +1,101 @@
|
||||
"use client";
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { PluggableList } from 'unified';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { PluggableList } from "unified";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { extractMarkdownHeadings } from "@/lib/markdown-headings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownSize = "sm" | "base" | "lg";
|
||||
type MarkdownSize = 'sm' | 'base' | 'lg';
|
||||
|
||||
type MarkdownProps = {
|
||||
content?: string;
|
||||
allowHtml?: boolean;
|
||||
className?: string;
|
||||
dir?: "rtl" | "ltr";
|
||||
dir?: 'rtl' | 'ltr';
|
||||
justify?: boolean;
|
||||
size?: MarkdownSize;
|
||||
};
|
||||
|
||||
function CodeBlock({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const language = /language-([\w-]+)/.exec(className || "")?.[1] || "text";
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
|
||||
const copyCode = async () => {
|
||||
if (!navigator.clipboard) return;
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="my-5 overflow-hidden rounded-2xl border border-slate-700/70 bg-[#0f172a] text-left shadow-xl shadow-slate-950/10"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-4 py-2 text-xs text-slate-300">
|
||||
<span className="font-mono uppercase tracking-wide">{language}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCode}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-slate-300 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
background: "transparent",
|
||||
direction: "ltr",
|
||||
padding: "1rem",
|
||||
}}
|
||||
codeTagProps={{
|
||||
dir: "ltr",
|
||||
style: {
|
||||
direction: "ltr",
|
||||
textAlign: "left",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Markdown({
|
||||
content = "",
|
||||
content = '',
|
||||
allowHtml = false,
|
||||
className = "",
|
||||
dir = "rtl",
|
||||
className = '',
|
||||
dir = 'rtl',
|
||||
justify = false,
|
||||
size = "sm",
|
||||
size = 'sm',
|
||||
}: MarkdownProps) {
|
||||
const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined;
|
||||
const headings = extractMarkdownHeadings(content);
|
||||
let headingIndex = 0;
|
||||
|
||||
const baseSizeClass =
|
||||
size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base";
|
||||
size === 'sm' ? 'text-sm' : size === 'lg' ? 'text-lg' : 'text-base';
|
||||
|
||||
const hScale =
|
||||
size === "sm"
|
||||
? { h1: "text-xl", h2: "text-lg", h3: "text-base", h4: "text-base" }
|
||||
: size === "base"
|
||||
? { h1: "text-3xl", h2: "text-2xl", h3: "text-xl", h4: "text-lg" }
|
||||
: { h1: "text-4xl", h2: "text-3xl", h3: "text-2xl", h4: "text-xl" };
|
||||
size === 'sm'
|
||||
? { h1: 'text-xl', h2: 'text-lg', h3: 'text-base', h4: 'text-base' }
|
||||
: size === 'base'
|
||||
? { h1: 'text-3xl', h2: 'text-2xl', h3: 'text-xl', h4: 'text-lg' }
|
||||
: { h1: 'text-4xl', h2: 'text-3xl', h3: 'text-2xl', h4: 'text-xl' };
|
||||
|
||||
const justifyStyle: React.CSSProperties | undefined = justify
|
||||
? { textAlign: "justify", textJustify: "inter-word" }
|
||||
? { textAlign: 'justify', textJustify: 'inter-word' }
|
||||
: undefined;
|
||||
|
||||
const nextHeadingId = (level: 1 | 2 | 3) => {
|
||||
while (headingIndex < headings.length) {
|
||||
const heading = headings[headingIndex];
|
||||
headingIndex += 1;
|
||||
if (heading.level === level) return heading.id;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
dir={dir}
|
||||
className={cn("markdown-body break-words text-right leading-8", baseSizeClass, className)}
|
||||
className={`markdown-body ${baseSizeClass} text-right leading-7 break-words ${className}`}
|
||||
style={justifyStyle}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={{
|
||||
h1: (p) => <h1 {...p} id={nextHeadingId(1)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h1)} style={{ ...p.style, textAlign: "right" }} />,
|
||||
h2: (p) => <h2 {...p} id={nextHeadingId(2)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h2)} style={{ ...p.style, textAlign: "right" }} />,
|
||||
h3: (p) => <h3 {...p} id={nextHeadingId(3)} className={cn("scroll-mt-28 pt-2 text-right font-semibold", hScale.h3)} style={{ ...p.style, textAlign: "right" }} />,
|
||||
h4: (p) => <h4 {...p} className={cn("mt-4 text-right font-semibold", hScale.h4)} style={{ ...p.style, textAlign: "right" }} />,
|
||||
p: (p) => <p className="my-4" {...p} />,
|
||||
a: (p) => <a className="break-all underline decoration-primary hover:opacity-90" target="_blank" rel="noopener noreferrer" {...p} />,
|
||||
ul: (p) => <ul className="my-4 list-disc space-y-1.5 pe-0 ps-6" {...p} />,
|
||||
ol: (p) => <ol className="my-4 list-decimal space-y-1.5 pe-0 ps-6" {...p} />,
|
||||
li: (p) => <li className="[&>ol]:my-1.5 [&>ul]:my-1.5" {...p} />,
|
||||
hr: (p) => <hr className="my-6 border-muted" {...p} />,
|
||||
h1: (p) => <h1 className={`mt-6 font-bold ${hScale.h1}`} {...p} />,
|
||||
h2: (p) => <h2 className={`mt-6 font-bold ${hScale.h2}`} {...p} />,
|
||||
h3: (p) => <h3 className={`mt-5 font-semibold ${hScale.h3}`} {...p} />,
|
||||
h4: (p) => <h4 className={`mt-4 font-semibold ${hScale.h4}`} {...p} />,
|
||||
p: (p) => <p className="my-3" {...p} />,
|
||||
a: (p) => <a className="underline decoration-primary hover:opacity-90 break-all" target="_blank" rel="noopener noreferrer" {...p} />,
|
||||
ul: (p) => <ul className="my-3 list-disc ps-6 space-y-1.5" {...p} />,
|
||||
ol: (p) => <ol className="my-3 list-decimal ps-6 space-y-1.5" {...p} />,
|
||||
li: (p) => <li className="[&>ul]:my-1.5 [&>ol]:my-1.5" {...p} />,
|
||||
hr: (p) => <hr className="my-5 border-muted" {...p} />,
|
||||
blockquote: (p) => (
|
||||
<blockquote className="my-4 rounded-2xl border-r-4 border-primary bg-muted/40 py-3 pr-4 italic text-muted-foreground" {...p} />
|
||||
<blockquote className="my-3 border-r-4 pr-4 italic text-muted-foreground" {...p} />
|
||||
),
|
||||
code: ({ className, children, node, ...p }) => {
|
||||
const isInline =
|
||||
node?.tagName === "code" &&
|
||||
!/language-/.test(className || "") &&
|
||||
!String(children).includes("\n");
|
||||
node?.tagName === 'code' &&
|
||||
!/language-/.test(className || '') &&
|
||||
!String(children).includes('\n');
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code dir="ltr" className="rounded bg-muted px-1.5 py-0.5 text-[0.9em]" {...p}>
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[0.9em]" {...p}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
return (
|
||||
<code className={className} {...p}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
pre: ({ className = '', children, ...p }) => (
|
||||
<pre
|
||||
className={[
|
||||
"my-4 overflow-x-auto rounded-md bg-muted p-4 text-[0.9em]",
|
||||
className,
|
||||
].filter(Boolean).join(" ")}
|
||||
{...p}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: (p) => (
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table className="w-full border-collapse" {...p} />
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,683 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ComponentType } from "react";
|
||||
import { defaultKeymap, history, historyKeymap, redo, undo } from "@codemirror/commands";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { bracketMatching, defaultHighlightStyle, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { search, searchKeymap } from "@codemirror/search";
|
||||
import { Compartment, EditorState, RangeSetBuilder, type Extension } from "@codemirror/state";
|
||||
import { tags } from "@lezer/highlight";
|
||||
import {
|
||||
Decoration,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
rectangularSelection,
|
||||
} from "@codemirror/view";
|
||||
import {
|
||||
Bold,
|
||||
Code2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
HelpCircle,
|
||||
Image as ImageIcon,
|
||||
IndentDecrease,
|
||||
IndentIncrease,
|
||||
Italic,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
Quote,
|
||||
Redo2,
|
||||
Strikethrough,
|
||||
Table2,
|
||||
TextCursorInput,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type MarkdownDirectionMode = "auto" | "rtl" | "ltr";
|
||||
|
||||
type MarkdownEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
minHeight?: string;
|
||||
directionMode?: MarkdownDirectionMode;
|
||||
onDirectionModeChange?: (mode: MarkdownDirectionMode) => void;
|
||||
onSave?: () => unknown | Promise<unknown>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type ToolbarAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
type InsertDialogState =
|
||||
| { type: "link"; from: number; to: number; selectedText: string }
|
||||
| { type: "image"; from: number; to: number; selectedText: string }
|
||||
| null;
|
||||
|
||||
const rtlStrongPattern = /[\u0590-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
|
||||
const ltrStrongPattern = /[A-Za-z0-9]/;
|
||||
const codeFontFamily =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||
|
||||
const codeFontHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: [tags.monospace, tags.processingInstruction],
|
||||
fontFamily: codeFontFamily,
|
||||
},
|
||||
]);
|
||||
|
||||
function detectLineDirection(text: string): "rtl" | "ltr" {
|
||||
for (const char of text.trimStart()) {
|
||||
if (rtlStrongPattern.test(char)) return "rtl";
|
||||
if (ltrStrongPattern.test(char)) return "ltr";
|
||||
}
|
||||
return "ltr";
|
||||
}
|
||||
|
||||
function directionExtension(mode: MarkdownDirectionMode): Extension {
|
||||
if (mode !== "auto") {
|
||||
return [
|
||||
EditorView.editorAttributes.of({ dir: mode }),
|
||||
EditorView.contentAttributes.of({ dir: mode }),
|
||||
EditorView.perLineTextDirection.of(false),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
EditorView.editorAttributes.of({ dir: "rtl" }),
|
||||
EditorView.perLineTextDirection.of(true),
|
||||
EditorView.decorations.compute(["doc"], (state) => {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber += 1) {
|
||||
const line = state.doc.line(lineNumber);
|
||||
builder.add(
|
||||
line.from,
|
||||
line.from,
|
||||
Decoration.line({ attributes: { dir: detectLineDirection(line.text) } }),
|
||||
);
|
||||
}
|
||||
return builder.finish();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function editorTheme(minHeight: string): Extension {
|
||||
return EditorView.theme({
|
||||
"&": {
|
||||
minHeight,
|
||||
backgroundColor: "hsl(var(--muted) / 0.28)",
|
||||
color: "hsl(var(--foreground))",
|
||||
fontSize: "14px",
|
||||
direction: "rtl",
|
||||
},
|
||||
".cm-scroller": {
|
||||
minHeight,
|
||||
fontFamily: "inherit",
|
||||
lineHeight: "1.9",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: "hsl(var(--primary))",
|
||||
padding: "1rem",
|
||||
backgroundColor: "hsl(var(--muted) / 0.16)",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0 0.25rem",
|
||||
unicodeBidi: "plaintext",
|
||||
},
|
||||
".cm-monospace": {
|
||||
fontFamily: codeFontFamily,
|
||||
},
|
||||
".cm-line[dir='rtl']": {
|
||||
textAlign: "right",
|
||||
},
|
||||
".cm-line[dir='ltr']": {
|
||||
textAlign: "left",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "hsl(var(--background) / 0.82)",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "hsl(var(--primary) / 0.2)",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "hsl(var(--primary))",
|
||||
},
|
||||
".cm-tooltip": {
|
||||
borderRadius: "0.75rem",
|
||||
borderColor: "hsl(var(--border))",
|
||||
backgroundColor: "hsl(var(--popover))",
|
||||
color: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
".cm-gutters": {
|
||||
display: "none",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function insertAtSelection(view: EditorView, text: string, cursorOffset = text.length) {
|
||||
const selection = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: text },
|
||||
selection: { anchor: selection.from + cursorOffset },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function wrapSelection(view: EditorView, before: string, after = before, placeholder = "متن") {
|
||||
const selection = view.state.selection.main;
|
||||
const selected = view.state.doc.sliceString(selection.from, selection.to) || placeholder;
|
||||
const insert = `${before}${selected}${after}`;
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert },
|
||||
selection: {
|
||||
anchor: selection.from + before.length,
|
||||
head: selection.from + before.length + selected.length,
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function selectedLines(view: EditorView) {
|
||||
const selection = view.state.selection.main;
|
||||
const startLine = view.state.doc.lineAt(selection.from);
|
||||
const endLine = view.state.doc.lineAt(selection.to);
|
||||
return { startLine, endLine };
|
||||
}
|
||||
|
||||
function prefixLines(view: EditorView, makePrefix: (index: number) => string) {
|
||||
const { startLine, endLine } = selectedLines(view);
|
||||
const changes: Array<{ from: number; insert: string }> = [];
|
||||
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
changes.push({ from: line.from, insert: makePrefix(lineNumber - startLine.number + 1) });
|
||||
}
|
||||
|
||||
view.dispatch({ changes, scrollIntoView: true });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function indentListLines(view: EditorView) {
|
||||
const { startLine, endLine } = selectedLines(view);
|
||||
const changes: Array<{ from: number; insert: string }> = [];
|
||||
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
if (/^\s*(?:[-*+]|\d+\.)(?:\s+\[[ xX]\])?\s+/.test(line.text)) {
|
||||
changes.push({ from: line.from, insert: " " });
|
||||
}
|
||||
}
|
||||
|
||||
if (!changes.length) return false;
|
||||
view.dispatch({ changes, scrollIntoView: true });
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function outdentListLines(view: EditorView) {
|
||||
const { startLine, endLine } = selectedLines(view);
|
||||
const changes: Array<{ from: number; to: number; insert: string }> = [];
|
||||
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const match = line.text.match(/^(\t| {1,2})/);
|
||||
if (match && /^\s*(?:[-*+]|\d+\.)(?:\s+\[[ xX]\])?\s+/.test(line.text)) {
|
||||
changes.push({ from: line.from, to: line.from + match[1].length, insert: "" });
|
||||
}
|
||||
}
|
||||
|
||||
if (!changes.length) return false;
|
||||
view.dispatch({ changes, scrollIntoView: true });
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setHeading(view: EditorView, level: 1 | 2 | 3) {
|
||||
const { startLine, endLine } = selectedLines(view);
|
||||
const changes: Array<{ from: number; to?: number; insert: string }> = [];
|
||||
const marker = `${"#".repeat(level)} `;
|
||||
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const match = line.text.match(/^(\s{0,3})(#{1,6}\s+)/);
|
||||
if (match) {
|
||||
const prefixStart = line.from + match[1].length;
|
||||
changes.push({ from: prefixStart, to: prefixStart + match[2].length, insert: marker });
|
||||
} else {
|
||||
changes.push({ from: line.from, insert: marker });
|
||||
}
|
||||
}
|
||||
|
||||
view.dispatch({ changes, scrollIntoView: true });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function wrapBlock(view: EditorView, before: string, after: string, placeholder: string) {
|
||||
const selection = view.state.selection.main;
|
||||
const selected = view.state.doc.sliceString(selection.from, selection.to) || placeholder;
|
||||
const insert = `${before}${selected}${after}`;
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert },
|
||||
selection: {
|
||||
anchor: selection.from + before.length,
|
||||
head: selection.from + before.length + selected.length,
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function getSelectedText(view: EditorView) {
|
||||
const selection = view.state.selection.main;
|
||||
return view.state.doc.sliceString(selection.from, selection.to);
|
||||
}
|
||||
|
||||
function insertLinkAtSelection(view: EditorView, from: number, to: number, selectedText: string, text: string, url: string) {
|
||||
const label = text.trim() || selectedText || "متن لینک";
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: `[${label}](${url})` },
|
||||
selection: { anchor: from + 1, head: from + 1 + label.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function insertImageAtSelection(view: EditorView, from: number, to: number, selectedText: string, altText: string, url: string) {
|
||||
const alt = altText.trim() || selectedText || "تصویر";
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: `` },
|
||||
selection: { anchor: from + 2, head: from + 2 + alt.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function runEditorCommand(view: EditorView | null, command: (view: EditorView) => void) {
|
||||
if (!view) return;
|
||||
command(view);
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
minHeight = "520px",
|
||||
directionMode = "auto",
|
||||
onDirectionModeChange,
|
||||
onSave,
|
||||
className,
|
||||
}: MarkdownEditorProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onSaveRef = useRef(onSave);
|
||||
const initialValueRef = useRef(value);
|
||||
const initialDirectionModeRef = useRef(directionMode);
|
||||
const directionCompartment = useRef(new Compartment());
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
const [insertDialog, setInsertDialog] = useState<InsertDialogState>(null);
|
||||
const [insertUrl, setInsertUrl] = useState("");
|
||||
const [insertText, setInsertText] = useState("");
|
||||
|
||||
onChangeRef.current = onChange;
|
||||
onSaveRef.current = onSave;
|
||||
|
||||
const openInsertDialog = (view: EditorView, type: "link" | "image") => {
|
||||
const selection = view.state.selection.main;
|
||||
const selectedText = getSelectedText(view);
|
||||
setInsertDialog({ type, from: selection.from, to: selection.to, selectedText });
|
||||
setInsertText(selectedText);
|
||||
setInsertUrl("");
|
||||
};
|
||||
|
||||
const closeInsertDialog = () => {
|
||||
setInsertDialog(null);
|
||||
setInsertText("");
|
||||
setInsertUrl("");
|
||||
viewRef.current?.focus();
|
||||
};
|
||||
|
||||
const submitInsertDialog = () => {
|
||||
const view = viewRef.current;
|
||||
const dialog = insertDialog;
|
||||
const url = insertUrl.trim();
|
||||
if (!view || !dialog || !url) return;
|
||||
|
||||
if (dialog.type === "link") {
|
||||
insertLinkAtSelection(view, dialog.from, dialog.to, dialog.selectedText, insertText, url);
|
||||
} else {
|
||||
insertImageAtSelection(view, dialog.from, dialog.to, dialog.selectedText, insertText, url);
|
||||
}
|
||||
closeInsertDialog();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return undefined;
|
||||
|
||||
const extensions: Extension[] = [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
rectangularSelection(),
|
||||
bracketMatching(),
|
||||
highlightActiveLine(),
|
||||
markdown(),
|
||||
search({ top: true }),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
syntaxHighlighting(codeFontHighlightStyle),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
keymap.of([
|
||||
{ key: "Mod-s", run: () => (void onSaveRef.current?.(), true) },
|
||||
{ key: "Mod-b", run: (view) => (wrapSelection(view, "**"), true) },
|
||||
{ key: "Mod-i", run: (view) => (wrapSelection(view, "_"), true) },
|
||||
{ key: "Mod-k", run: (view) => (openInsertDialog(view, "link"), true) },
|
||||
{ key: "Mod-/", run: () => (setGuideOpen(true), true) },
|
||||
{ key: "Mod-Shift-7", run: (view) => (prefixLines(view, (index) => `${index}. `), true) },
|
||||
{ key: "Mod-Shift-8", run: (view) => (prefixLines(view, () => "- "), true) },
|
||||
{ key: "Mod-e", run: (view) => (wrapSelection(view, "`"), true) },
|
||||
{ key: "Mod-Alt-c", run: (view) => (wrapBlock(view, "\n```text\n", "\n```\n", "code"), true) },
|
||||
{ key: "Mod-Alt-i", run: (view) => (openInsertDialog(view, "image"), true) },
|
||||
{ key: "Mod-Alt-1", run: (view) => (setHeading(view, 1), true) },
|
||||
{ key: "Mod-Alt-2", run: (view) => (setHeading(view, 2), true) },
|
||||
{ key: "Mod-Alt-3", run: (view) => (setHeading(view, 3), true) },
|
||||
{
|
||||
key: "Mod-Alt-t",
|
||||
run: (view) => (
|
||||
insertAtSelection(view, "\n| ستون اول | ستون دوم |\n| --- | --- |\n| مقدار | مقدار |\n"),
|
||||
true
|
||||
),
|
||||
},
|
||||
{ key: "Mod-Alt-x", run: (view) => (prefixLines(view, () => "- [ ] "), true) },
|
||||
{ key: "Mod-Shift-x", run: (view) => (wrapSelection(view, "~~"), true) },
|
||||
{ key: "Tab", run: indentListLines },
|
||||
{ key: "Shift-Tab", run: outdentListLines },
|
||||
...searchKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
editorTheme(minHeight),
|
||||
directionCompartment.current.of(directionExtension(initialDirectionModeRef.current)),
|
||||
];
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: initialValueRef.current,
|
||||
extensions,
|
||||
});
|
||||
const view = new EditorView({ state, parent: hostRef.current });
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [minHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (current === value) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: directionCompartment.current.reconfigure(directionExtension(directionMode)),
|
||||
});
|
||||
}, [directionMode]);
|
||||
|
||||
const setDirectionMode = (mode: MarkdownDirectionMode) => {
|
||||
onDirectionModeChange?.(mode);
|
||||
};
|
||||
|
||||
const actions: ToolbarAction[] = [
|
||||
{ key: "bold", label: "درشت (Ctrl+B)", icon: Bold, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "**")) },
|
||||
{ key: "italic", label: "کج (Ctrl+I)", icon: Italic, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "_")) },
|
||||
{
|
||||
key: "strike",
|
||||
label: "خطخورده (Ctrl+Shift+X)",
|
||||
icon: Strikethrough,
|
||||
run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "~~")),
|
||||
},
|
||||
{ key: "h1", label: "تیتر ۱ (Ctrl+Alt+1)", icon: Heading1, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 1)) },
|
||||
{ key: "h2", label: "تیتر ۲ (Ctrl+Alt+2)", icon: Heading2, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 2)) },
|
||||
{ key: "h3", label: "تیتر ۳ (Ctrl+Alt+3)", icon: Heading3, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 3)) },
|
||||
{ key: "quote", label: "نقل قول", icon: Quote, run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "> ")) },
|
||||
{ key: "ul", label: "فهرست نقطهای (Ctrl+Shift+8)", icon: List, run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "- ")) },
|
||||
{
|
||||
key: "ol",
|
||||
label: "فهرست شمارهای (Ctrl+Shift+7)",
|
||||
icon: ListOrdered,
|
||||
run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, (index) => `${index}. `)),
|
||||
},
|
||||
{
|
||||
key: "task-list",
|
||||
label: "فهرست کارها (Ctrl+Alt+X)",
|
||||
icon: ListChecks,
|
||||
run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "- [ ] ")),
|
||||
},
|
||||
{ key: "link", label: "لینک (Ctrl+K)", icon: LinkIcon, run: () => runEditorCommand(viewRef.current, (view) => openInsertDialog(view, "link")) },
|
||||
{ key: "image", label: "تصویر (Ctrl+Alt+I)", icon: ImageIcon, run: () => runEditorCommand(viewRef.current, (view) => openInsertDialog(view, "image")) },
|
||||
{ key: "inline-code", label: "کد کوتاه (Ctrl+E)", icon: TextCursorInput, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "`", "`", "code")) },
|
||||
{
|
||||
key: "code",
|
||||
label: "بلوک کد (Ctrl+Alt+C)",
|
||||
icon: Code2,
|
||||
run: () => runEditorCommand(viewRef.current, (view) => wrapBlock(view, "\n```text\n", "\n```\n", "code")),
|
||||
},
|
||||
{
|
||||
key: "table",
|
||||
label: "جدول (Ctrl+Alt+T)",
|
||||
icon: Table2,
|
||||
run: () =>
|
||||
runEditorCommand(viewRef.current, (view) =>
|
||||
insertAtSelection(view, "\n| ستون اول | ستون دوم |\n| --- | --- |\n| مقدار | مقدار |\n"),
|
||||
),
|
||||
},
|
||||
{ key: "hr", label: "خط جداکننده", icon: Minus, run: () => runEditorCommand(viewRef.current, (view) => insertAtSelection(view, "\n---\n")) },
|
||||
{ key: "undo", label: "بازگردانی (Ctrl+Z)", icon: Undo2, run: () => runEditorCommand(viewRef.current, (view) => undo(view)) },
|
||||
{ key: "redo", label: "انجام دوباره (Ctrl+Shift+Z)", icon: Redo2, run: () => runEditorCommand(viewRef.current, (view) => redo(view)) },
|
||||
];
|
||||
|
||||
const shortcutSections = [
|
||||
{
|
||||
title: "قالببندی متن",
|
||||
items: [
|
||||
["Ctrl/Cmd + B", "درشت کردن متن"],
|
||||
["Ctrl/Cmd + I", "کج کردن متن"],
|
||||
["Ctrl/Cmd + Shift + X", "خطخورده"],
|
||||
["Ctrl/Cmd + E", "کد کوتاه درونخطی"],
|
||||
["Ctrl/Cmd + Alt + 1/2/3", "تیترهای سطح ۱، ۲ و ۳"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "بلوکها و ساختار",
|
||||
items: [
|
||||
["Ctrl/Cmd + Shift + 8", "فهرست نقطهای"],
|
||||
["Ctrl/Cmd + Shift + 7", "فهرست شمارهای"],
|
||||
["Ctrl/Cmd + Alt + X", "فهرست کارها"],
|
||||
["Ctrl/Cmd + Alt + C", "بلوک کد"],
|
||||
["Ctrl/Cmd + Alt + T", "جدول"],
|
||||
["Tab / Shift + Tab", "تورفتگی یا خروج از تورفتگی برای آیتمهای فهرست"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "درج محتوا",
|
||||
items: [
|
||||
["Ctrl/Cmd + K", "لینک"],
|
||||
["Ctrl/Cmd + Alt + I", "تصویر"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "عملیات و راهنما",
|
||||
items: [
|
||||
["Ctrl/Cmd + S", "ذخیره پیشنویس"],
|
||||
["Ctrl/Cmd + F", "جستوجو در متن ویرایشگر"],
|
||||
["Ctrl/Cmd + Z", "بازگردانی"],
|
||||
["Ctrl/Cmd + Shift + Z", "انجام دوباره"],
|
||||
["Ctrl/Cmd + /", "باز کردن همین راهنما"],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("overflow-hidden rounded-2xl border bg-muted/30 shadow-inner", className)} dir="rtl">
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b bg-background/80 p-2">
|
||||
<div className="flex flex-wrap items-center justify-start gap-1">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Tooltip key={action.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={action.run}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="sr-only">{action.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-full border bg-background/80 p-1">
|
||||
{(["auto", "rtl", "ltr"] as const).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
variant={directionMode === mode ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 rounded-full px-3 text-xs"
|
||||
onClick={() => setDirectionMode(mode)}
|
||||
>
|
||||
{mode === "auto" ? "Auto" : mode.toUpperCase()}
|
||||
</Button>
|
||||
))}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 rounded-full" onClick={() => setGuideOpen(true)}>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">راهنمای میانبرها</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>راهنمای میانبرها</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div ref={hostRef} className="text-left" />
|
||||
<Dialog open={guideOpen} onOpenChange={setGuideOpen}>
|
||||
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto rounded-3xl" dir="rtl">
|
||||
<DialogHeader className="text-right md:text-right mt-6 mb-2">
|
||||
<DialogTitle>راهنمای میانبرهای ویرایشگر مارکداون</DialogTitle>
|
||||
<DialogDescription>
|
||||
در ویندوز و لینوکس از Ctrl و در مک از Cmd استفاده کنید. میانبرها فقط وقتی ویرایشگر فعال است اجرا میشوند.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{shortcutSections.map((section) => (
|
||||
<section key={section.title} className="rounded-2xl border bg-muted/20 p-4">
|
||||
<h3 className="mb-3 text-right font-bold">{section.title}</h3>
|
||||
<div className="space-y-2">
|
||||
{section.items.map(([shortcut, description]) => (
|
||||
<div key={`${section.title}-${shortcut}`} className="flex items-center justify-between gap-3 rounded-xl bg-background/80 px-3 py-2 text-sm">
|
||||
<span className="text-right text-muted-foreground">{description}</span>
|
||||
<kbd className="shrink-0 rounded-lg border bg-muted px-2 py-1 font-mono text-[11px] leading-none text-foreground">
|
||||
{shortcut}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(insertDialog)} onOpenChange={(open) => (open ? undefined : closeInsertDialog())}>
|
||||
<DialogContent className="max-w-lg rounded-3xl" dir="rtl">
|
||||
<DialogHeader className="mt-6 text-right md:text-right">
|
||||
<DialogTitle>{insertDialog?.type === "image" ? "درج تصویر" : "درج لینک"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{insertDialog?.type === "image"
|
||||
? "آدرس تصویر و متن جایگزین را وارد کنید. برای فایلهای آپلودشده میتوانید لینک را از مرکز آپلود کپی کنید."
|
||||
: "آدرس مقصد و متن لینک را وارد کنید. اگر متن انتخاب کرده باشید، به عنوان متن لینک استفاده میشود."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitInsertDialog();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="markdown-insert-url" className="block text-right">
|
||||
آدرس
|
||||
</Label>
|
||||
<Input
|
||||
id="markdown-insert-url"
|
||||
value={insertUrl}
|
||||
onChange={(event) => setInsertUrl(event.target.value)}
|
||||
placeholder={insertDialog?.type === "image" ? "https://example.com/image.png" : "https://example.com"}
|
||||
dir="ltr"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="markdown-insert-text" className="block text-right">
|
||||
{insertDialog?.type === "image" ? "متن جایگزین" : "متن لینک"}
|
||||
</Label>
|
||||
<Input
|
||||
id="markdown-insert-text"
|
||||
value={insertText}
|
||||
onChange={(event) => setInsertText(event.target.value)}
|
||||
placeholder={insertDialog?.type === "image" ? "توضیح کوتاه تصویر" : "متن قابل کلیک"}
|
||||
className="text-right"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-start gap-2 pt-2">
|
||||
<Button type="submit" disabled={!insertUrl.trim()}>
|
||||
{insertDialog?.type === "image" ? "درج تصویر" : "درج لینک"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={closeInsertDialog}>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { CalendarDays, CircleUserRound, Home, Newspaper } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type NavItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
matches: (pathname: string) => boolean;
|
||||
};
|
||||
|
||||
const isProfileOrAuthPath = (pathname: string) =>
|
||||
pathname === "/profile" ||
|
||||
pathname.startsWith("/profile/") ||
|
||||
pathname === "/auth" ||
|
||||
pathname.startsWith("/auth/") ||
|
||||
pathname.startsWith("/reset-password") ||
|
||||
pathname.startsWith("/verify-email");
|
||||
|
||||
export default function MobileBottomNav() {
|
||||
const pathname = usePathname() || "/";
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (pathname.startsWith("/admin") || pathname === "/logout" || pathname.startsWith("/auth/google")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
key: "home",
|
||||
label: "خانه",
|
||||
href: "/",
|
||||
icon: Home,
|
||||
matches: (current) => current === "/",
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
label: "رویدادها",
|
||||
href: "/events",
|
||||
icon: CalendarDays,
|
||||
matches: (current) => current === "/events" || current.startsWith("/events/"),
|
||||
},
|
||||
{
|
||||
key: "blog",
|
||||
label: "بلاگ",
|
||||
href: "/blog",
|
||||
icon: Newspaper,
|
||||
matches: (current) => current === "/blog" || current.startsWith("/blog/"),
|
||||
},
|
||||
{
|
||||
key: "account",
|
||||
label: isAuthenticated ? "پروفایل" : "حساب",
|
||||
href: isAuthenticated ? "/profile" : "/auth",
|
||||
icon: CircleUserRound,
|
||||
matches: isProfileOrAuthPath,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 z-50 px-4 md:hidden"
|
||||
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
|
||||
>
|
||||
<nav
|
||||
aria-label="Mobile navigation"
|
||||
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
|
||||
dir="rtl"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = item.matches(pathname);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[11px] font-medium transition-all",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, LogOut, ShieldCheck, Smartphone } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/lib/api";
|
||||
import OtpCodeField from "@/components/OtpCodeField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
const toEnglishDigits = (value: string) =>
|
||||
value
|
||||
.replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0))
|
||||
.replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660))
|
||||
.replace(/[^\d]/g, "");
|
||||
|
||||
export default function MobileVerificationGate() {
|
||||
const { user, isAuthenticated, loading, refreshProfile, setUser, logout } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [step, setStep] = useState<"collect" | "verify">("collect");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.mobile) {
|
||||
setMobile(user.mobile);
|
||||
setStep("verify");
|
||||
}
|
||||
}, [user?.mobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setCooldown((current) => current - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
const handleSendOtp = async () => {
|
||||
try {
|
||||
setSending(true);
|
||||
const normalizedMobile = toEnglishDigits(mobile);
|
||||
const response = await api.sendMobileVerificationOtp({ mobile: normalizedMobile });
|
||||
setMobile(normalizedMobile);
|
||||
setStep("verify");
|
||||
setCooldown(Math.min(response.expires_in_seconds, 120));
|
||||
toast({
|
||||
title: "کد تأیید ارسال شد",
|
||||
description: response.message,
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "خطا در ارسال کد",
|
||||
description: resolveErrorMessage(error, "ارسال کد تأیید انجام نشد."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
try {
|
||||
setVerifying(true);
|
||||
const profile = await api.verifyMobile({
|
||||
mobile: toEnglishDigits(mobile),
|
||||
code: toEnglishDigits(code),
|
||||
});
|
||||
setUser(profile);
|
||||
await refreshProfile();
|
||||
toast({
|
||||
title: "شماره موبایل تأیید شد",
|
||||
description: "از این پس میتوانید با موبایل و کد پیامکی وارد شوید.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "کد نامعتبر است",
|
||||
description: resolveErrorMessage(error, "تأیید شماره موبایل انجام نشد."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !isAuthenticated || !user?.requires_mobile_verification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-slate-950/70 px-4 backdrop-blur-md" dir="rtl">
|
||||
<div className="w-full max-w-lg rounded-[2rem] border border-white/10 bg-background/95 p-6 shadow-[0_30px_80px_rgba(15,23,42,0.35)]">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-2xl"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="ml-2 h-4 w-4" />
|
||||
خروج
|
||||
</Button>
|
||||
<div className="text-right">
|
||||
<div className="mb-3 inline-flex rounded-2xl border border-primary/20 bg-primary/10 p-3 text-primary">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">تکمیل امنیت حساب با موبایل</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
برای ادامه استفاده از سایت باید شماره موبایل خود را ثبت و با کد پیامکی تأیید کنید.
|
||||
ورودهای بعدی و بازیابی حساب شما از همین مسیر انجام میشود.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-muted/20 p-4">
|
||||
<div className="mb-4 flex items-center justify-end gap-3">
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{user.first_name || user.last_name ? `${user.first_name} ${user.last_name}`.trim() : user.username}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{user.email ? `ایمیل متصل: ${user.email}` : "ایمیل برای این حساب اختیاری است."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 p-3">
|
||||
<Smartphone className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="required-mobile" className="mb-2 block text-right">
|
||||
شماره موبایل
|
||||
</Label>
|
||||
<Input
|
||||
id="required-mobile"
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
value={mobile}
|
||||
onChange={(event) => setMobile(toEnglishDigits(event.target.value))}
|
||||
placeholder="09xxxxxxxxx"
|
||||
className="h-12 rounded-2xl"
|
||||
disabled={sending || verifying}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{step === "verify" ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-3 block text-right">کد تأیید پیامکی</Label>
|
||||
<OtpCodeField
|
||||
value={code}
|
||||
onChange={(value) => setCode(toEnglishDigits(value))}
|
||||
disabled={verifying}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 rounded-2xl"
|
||||
onClick={() => void handleVerify()}
|
||||
disabled={verifying || code.length !== 5}
|
||||
>
|
||||
{verifying ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال تأیید...
|
||||
</>
|
||||
) : (
|
||||
"تأیید و ادامه"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-2xl"
|
||||
onClick={() => void handleSendOtp()}
|
||||
disabled={sending || cooldown > 0}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
ارسال...
|
||||
</>
|
||||
) : cooldown > 0 ? (
|
||||
`ارسال مجدد تا ${cooldown} ثانیه`
|
||||
) : (
|
||||
"ارسال مجدد کد"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full rounded-2xl"
|
||||
onClick={() => void handleSendOtp()}
|
||||
disabled={sending || mobile.length !== 11}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال ارسال کد...
|
||||
</>
|
||||
) : (
|
||||
"ارسال کد تأیید"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,33 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
// src/components/ModeToggle.tsx
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
|
||||
export default function ModeToggle({ className }: { className?: string }) {
|
||||
export default function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const handleToggle = () => {
|
||||
if (theme === "system" && typeof window !== "undefined") {
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
||||
setTheme(prefersDark ? "light" : "dark");
|
||||
if (theme === 'system' && typeof window !== 'undefined') {
|
||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
setTheme(prefersDark ? 'light' : 'dark');
|
||||
return;
|
||||
}
|
||||
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const isDark =
|
||||
theme === "dark" ||
|
||||
(theme === "system" &&
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark"));
|
||||
theme === 'dark' ||
|
||||
(theme === 'system' &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement.classList.contains('dark'));
|
||||
|
||||
const nextThemeLabel = isDark ? "روشن" : "تاریک";
|
||||
const nextThemeLabel = isDark ? 'روشن' : 'تاریک';
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
aria-label={`تغییر تم به حالت ${nextThemeLabel}`}
|
||||
title={`تغییر تم به حالت ${nextThemeLabel}`}
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { LayoutDashboard, LogOut, RotateCcw, UserRound } from "lucide-react";
|
||||
import { Link, NavLink } from "@/lib/router";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import ModeToggle from "@/components/ModeToggle";
|
||||
import NotificationsBell from "@/components/NotificationsBell";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, NavLink, useNavigate } from '@/lib/router';
|
||||
import { Menu, ChevronDown } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import ModeToggle from '@/components/ModeToggle';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,142 +15,190 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
const NavItem = ({ to, children }: { to: string; children: ReactNode }) => (
|
||||
const NavItem = ({
|
||||
to,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"rounded-full px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)
|
||||
[
|
||||
'px-2 py-1 rounded-md transition-colors',
|
||||
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
function ProfileAvatarMenu() {
|
||||
export default function Navbar() {
|
||||
const navigate = useNavigate();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const isAdminUser = isAuthenticated && Boolean(user?.is_staff || user?.is_superuser);
|
||||
const isAdminUser = isAuthenticated && ((user?.is_staff || user?.is_superuser) ?? false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const avatarInitials = useMemo(
|
||||
() =>
|
||||
(user?.first_name?.[0] ||
|
||||
user?.last_name?.[0] ||
|
||||
user?.username?.[0] ||
|
||||
"?").toUpperCase(),
|
||||
() => (user?.first_name?.[0] || user?.last_name?.[0] || user?.username?.[0] || '?').toUpperCase(),
|
||||
[user?.first_name, user?.last_name, user?.username],
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Link to="/auth">
|
||||
<Button className="rounded-full px-5">ورود / ثبتنام</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu dir="rtl">
|
||||
const UserDropdown = () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-full border-0 bg-transparent p-0 shadow-none transition hover:bg-background/45"
|
||||
aria-label="منوی حساب کاربری"
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-muted/40 px-2 py-1 pr-2.5 transition hover:bg-muted"
|
||||
>
|
||||
<Avatar className="h-10 w-10 border border-white/30 shadow-sm">
|
||||
<AvatarImage
|
||||
src={
|
||||
user?.profile_picture_preview_url ||
|
||||
user?.profile_picture ||
|
||||
undefined
|
||||
}
|
||||
alt={user?.username || "profile"}
|
||||
/>
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={user?.profile_picture || undefined} alt={user?.username || 'profile'} />
|
||||
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={12} className="w-64 rounded-2xl p-2 text-right">
|
||||
<DropdownMenuLabel className="text-right">
|
||||
{[user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.username || "حساب کاربری"}
|
||||
<DropdownMenuContent align="end" className="w-56 text-right">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{user?.first_name || user?.last_name ? `${user?.first_name || ''} ${user?.last_name || ''}`.trim() : user?.username}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||
<Link to="/profile">
|
||||
مشاهده پروفایل
|
||||
<UserRound className="h-4 w-4" />
|
||||
</Link>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/profile">پروفایل</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||
<Link to="/reset-password">
|
||||
تغییر یا بازیابی رمز
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdminUser ? (
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||
<Link to="/admin">
|
||||
داشبورد مدیریت
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
</Link>
|
||||
{isAdminUser && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin">داشبورد مدیریت</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span>حالت نمایش</span>
|
||||
<ModeToggle />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl text-destructive focus:text-destructive">
|
||||
<Link to="/logout">
|
||||
خروج از حساب
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
navigate('/logout');
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
خروج
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="sticky top-0 z-40 border-b bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
|
||||
dir="rtl"
|
||||
>
|
||||
<nav className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Link to="/" className="flex min-w-0 items-center gap-3">
|
||||
<div className="hidden rounded-2xl sm:flex">
|
||||
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-10 w-10 object-contain" />
|
||||
</div>
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
انجمن علمی مهندسی کامپیوتر
|
||||
</p>
|
||||
<p className="hidden text-xs text-muted-foreground sm:block">
|
||||
دانشکدهی فنی و مهندسی شرق گیلان
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse items-center justify-between gap-3">
|
||||
<Link to="/" className="order-2 flex items-center gap-2">
|
||||
<span className="sm:inline text-2xl font-bold text-primary">
|
||||
انجمن علمی کامپیوتر گیلان
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<div className="order-1 hidden md:flex items-center gap-2">
|
||||
<NavItem to="/">خانه</NavItem>
|
||||
<NavItem to="/blog">بلاگ</NavItem>
|
||||
<NavItem to="/events">رویدادها</NavItem>
|
||||
<ModeToggle />
|
||||
{isAuthenticated ? <NotificationsBell /> : null}
|
||||
<ProfileAvatarMenu />
|
||||
{isAuthenticated ? (
|
||||
<UserDropdown />
|
||||
) : (
|
||||
<>
|
||||
<Link to="/auth">
|
||||
<Button size="sm">ورود / ثبتنام</Button>
|
||||
</Link>
|
||||
<ModeToggle />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
{isAuthenticated ? <NotificationsBell /> : null}
|
||||
<ModeToggle />
|
||||
<ProfileAvatarMenu />
|
||||
<div className="order-1 md:hidden">
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label="U.U+U^">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[80vw] sm:w-[360px]" dir="rtl">
|
||||
<div className="mt-6 flex flex-col gap-4 text-right">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<img src="/favicon.ico" alt="لوگو" className="h-8 w-auto" height={32} width={32} />
|
||||
<span className="text-xl font-semibold text-primary">انجمن علمی کامپیوتر گیلان</span>
|
||||
</Link>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<NavItem to="/" onClick={() => setOpen(false)}>خانه</NavItem>
|
||||
<NavItem to="/blog" onClick={() => setOpen(false)}>بلاگ</NavItem>
|
||||
<NavItem to="/events" onClick={() => setOpen(false)}>رویدادها</NavItem>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t grid gap-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user?.profile_picture || undefined} alt={user?.username || 'profile'} />
|
||||
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="font-medium">{user?.username}</div>
|
||||
{user?.email ? <div className="text-xs text-muted-foreground">{user.email}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Button variant="ghost" className="justify-between" asChild onClick={() => setOpen(false)}>
|
||||
<Link to="/profile">پروفایل</Link>
|
||||
</Button>
|
||||
{isAdminUser && (
|
||||
<Button variant="ghost" className="justify-between" asChild onClick={() => setOpen(false)}>
|
||||
<Link to="/admin">داشبورد مدیریت</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">حالت نمایش</span>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-between text-red-600 border-red-600 hover:bg-red-50 dark:text-red-400 dark:border-red-400 dark:hover:bg-red-950/30"
|
||||
onClick={() => { setOpen(false); navigate('/logout'); }}
|
||||
>
|
||||
خروج
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<Link to="/auth" onClick={() => setOpen(false)}>
|
||||
<Button className="w-full">ورود / ثبتنام</Button>
|
||||
</Link>
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">حالت نمایش</span>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useNotifications } from "@/contexts/NotificationsContext";
|
||||
import type { NotificationSchema } from "@/lib/types";
|
||||
import { cn, formatJalali } from "@/lib/utils";
|
||||
|
||||
const connectionLabels = {
|
||||
idle: "خاموش",
|
||||
connecting: "در حال اتصال",
|
||||
connected: "متصل",
|
||||
disconnected: "قطع شده",
|
||||
} as const;
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
notification: NotificationSchema;
|
||||
onOpen: (notification: NotificationSchema) => Promise<unknown>;
|
||||
onDelete: (notification: NotificationSchema) => Promise<unknown>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border/70 bg-background/75 p-3 text-right transition hover:border-primary/30 hover:bg-muted/35",
|
||||
!notification.is_seen && "border-primary/30 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<ConfirmAction
|
||||
title="حذف اعلان"
|
||||
description="آیا از حذف این اعلان مطمئن هستید؟"
|
||||
onConfirm={() => onDelete(notification)}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onOpen(notification)}
|
||||
className="min-w-0 flex-1 space-y-1 text-right"
|
||||
>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!notification.is_seen ? (
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
) : null}
|
||||
<p className="truncate font-semibold">{notification.title}</p>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{formatJalali(notification.created_at, false)}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationsBell() {
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
totalCount,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
connectionStatus,
|
||||
loadMore,
|
||||
markAllAsSeen,
|
||||
deleteNotification,
|
||||
openNotification,
|
||||
} = useNotifications();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-10 w-10 rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm"
|
||||
aria-label="اعلانها"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -left-1 -top-1 flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[min(92vw,26rem)] rounded-[1.5rem] border border-border/70 bg-background/95 p-0 shadow-xl backdrop-blur-xl" align="end" sideOffset={12}>
|
||||
<div className="border-b border-border/70 px-4 py-4 text-right">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={connectionStatus === "connected" ? "default" : "secondary"}>
|
||||
{connectionLabels[connectionStatus]}
|
||||
</Badge>
|
||||
{unreadCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => void markAllAsSeen()}
|
||||
>
|
||||
<CheckCheck className="ml-2 h-4 w-4" />
|
||||
خواندن همه
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">اعلانها</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{totalCount > 0 ? `${totalCount} مورد ثبت شده` : "هنوز اعلانی ندارید."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[24rem] px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
در حال بارگذاری اعلانها...
|
||||
</div>
|
||||
) : notifications.length ? (
|
||||
notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onOpen={openNotification}
|
||||
onDelete={deleteNotification}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
اعلان تازهای برای شما ثبت نشده است.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{hasMore ? (
|
||||
<div className="border-t border-border/70 px-4 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full rounded-2xl"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال بارگذاری...
|
||||
</>
|
||||
) : (
|
||||
"نمایش موارد بیشتر"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OtpCodeFieldProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
length?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function OtpCodeField({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
length = 5,
|
||||
className,
|
||||
}: OtpCodeFieldProps) {
|
||||
return (
|
||||
<InputOTP
|
||||
dir="ltr"
|
||||
maxLength={length}
|
||||
value={value}
|
||||
onChange={(nextValue) => onChange(nextValue.replace(/[^\d]/g, ""))}
|
||||
disabled={disabled}
|
||||
inputMode="numeric"
|
||||
containerClassName={cn("w-full justify-center", className)}
|
||||
>
|
||||
<InputOTPGroup dir="ltr" className="justify-center">
|
||||
{Array.from({ length }).map((_, index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
className="h-12 w-11 rounded-2xl border border-border/70 bg-background/80 text-base shadow-sm first:rounded-2xl first:border last:rounded-2xl"
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ProgressiveImageProps = {
|
||||
src?: string | null;
|
||||
blurSrc?: string | null;
|
||||
alt: string;
|
||||
wrapperClassName?: string;
|
||||
className?: string;
|
||||
fallbackSrc?: string;
|
||||
sizes?: string;
|
||||
loading?: "eager" | "lazy";
|
||||
decoding?: "async" | "sync" | "auto";
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
};
|
||||
|
||||
const DEFAULT_FALLBACK = "/placeholder.svg";
|
||||
|
||||
export default function ProgressiveImage({
|
||||
src,
|
||||
blurSrc,
|
||||
alt,
|
||||
wrapperClassName,
|
||||
className,
|
||||
fallbackSrc = DEFAULT_FALLBACK,
|
||||
sizes,
|
||||
loading = "lazy",
|
||||
decoding = "async",
|
||||
onClick,
|
||||
}: ProgressiveImageProps) {
|
||||
const resolvedSrc = src || blurSrc || fallbackSrc;
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLoaded(false);
|
||||
}, [resolvedSrc]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden bg-muted", wrapperClassName)}>
|
||||
{!loaded && (
|
||||
<>
|
||||
{blurSrc ? (
|
||||
<img
|
||||
src={blurSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 animate-pulse bg-muted/80" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={alt}
|
||||
sizes={sizes}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
onClick={onClick}
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={cn(
|
||||
"h-full w-full object-cover transition-opacity duration-300",
|
||||
loaded ? "opacity-100" : "opacity-0",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
completeNavigationProgress,
|
||||
subscribeNavigationProgress,
|
||||
} from "@/lib/navigation-progress";
|
||||
|
||||
const START_VALUE = 18;
|
||||
const MAX_ACTIVE_VALUE = 90;
|
||||
|
||||
export default function RouteProgress() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [progress, setProgress] = React.useState(0);
|
||||
const intervalRef = React.useRef<number | null>(null);
|
||||
const finishTimeoutRef = React.useRef<number | null>(null);
|
||||
const safetyTimeoutRef = React.useRef<number | null>(null);
|
||||
const activeRef = React.useRef(false);
|
||||
const routeKey = `${pathname ?? ""}?${searchParams?.toString() ?? ""}`;
|
||||
|
||||
const clearTimers = React.useCallback(() => {
|
||||
if (intervalRef.current !== null) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (finishTimeoutRef.current !== null) {
|
||||
window.clearTimeout(finishTimeoutRef.current);
|
||||
finishTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (safetyTimeoutRef.current !== null) {
|
||||
window.clearTimeout(safetyTimeoutRef.current);
|
||||
safetyTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finish = React.useCallback(() => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeRef.current = false;
|
||||
clearTimers();
|
||||
setProgress(100);
|
||||
|
||||
finishTimeoutRef.current = window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
setProgress(0);
|
||||
}, 220);
|
||||
}, [clearTimers]);
|
||||
|
||||
const start = React.useCallback(() => {
|
||||
clearTimers();
|
||||
activeRef.current = true;
|
||||
setVisible(true);
|
||||
setProgress(START_VALUE);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
setProgress(28);
|
||||
});
|
||||
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setProgress((current) => {
|
||||
if (current >= MAX_ACTIVE_VALUE) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const delta = Math.max((MAX_ACTIVE_VALUE - current) * 0.14, 1.5);
|
||||
return Math.min(MAX_ACTIVE_VALUE, current + delta);
|
||||
});
|
||||
}, 180);
|
||||
|
||||
safetyTimeoutRef.current = window.setTimeout(() => {
|
||||
finish();
|
||||
}, 12000);
|
||||
}, [clearTimers, finish]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return subscribeNavigationProgress((event) => {
|
||||
if (event === "start") {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
|
||||
finish();
|
||||
});
|
||||
}, [finish, start]);
|
||||
|
||||
React.useEffect(() => {
|
||||
finish();
|
||||
}, [finish, routeKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed inset-x-0 top-0 z-[100] h-1 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 transition-opacity duration-200 ${
|
||||
visible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="h-full origin-right bg-[linear-gradient(90deg,hsl(var(--primary))_0%,hsl(var(--route-progress))_100%)] shadow-[0_0_16px_hsl(var(--route-progress)/0.55)] transition-[transform] duration-200 ease-out"
|
||||
style={{ transform: `scaleX(${progress / 100})` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function BlogCardsSkeleton({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 2xl:grid-cols-3" aria-hidden="true">
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="overflow-hidden rounded-[2rem] border border-border/70 bg-card/85 shadow-sm"
|
||||
>
|
||||
<Skeleton className="aspect-[16/10] w-full rounded-t-[2rem] rounded-b-none" />
|
||||
<div className="space-y-4 p-5">
|
||||
<Skeleton className="h-7 w-11/12" />
|
||||
<Skeleton className="h-7 w-3/4" />
|
||||
<div className="space-y-2 pt-1">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-10/12" />
|
||||
<Skeleton className="h-4 w-8/12" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlogListingPageLoading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.32))]" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<Skeleton className="mb-8 aspect-[5/1.25] w-full rounded-[2rem] md:aspect-[6/1.25]" />
|
||||
|
||||
<div className="mb-8 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-3 text-right">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="h-11 w-28" />
|
||||
<Skeleton className="h-5 w-full max-w-xl" />
|
||||
<Skeleton className="h-5 w-10/12 max-w-lg" />
|
||||
</div>
|
||||
<div className="flex w-full max-w-md items-center gap-2">
|
||||
<Skeleton className="h-12 flex-1 rounded-2xl" />
|
||||
<Skeleton className="h-12 w-12 rounded-2xl xl:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[18rem_minmax(0,1fr)] xl:items-start">
|
||||
<aside className="hidden xl:block">
|
||||
<div className="sticky top-24 space-y-4 rounded-[2rem] border border-border/70 bg-card/80 p-4 shadow-sm">
|
||||
<Skeleton className="h-7 w-32" />
|
||||
<Skeleton className="h-32 w-full rounded-3xl" />
|
||||
<Skeleton className="h-28 w-full rounded-3xl" />
|
||||
<Skeleton className="h-40 w-full rounded-3xl" />
|
||||
</div>
|
||||
</aside>
|
||||
<BlogCardsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlogDetailPageLoading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.28))]" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="gap-8 xl:flex xl:items-start">
|
||||
<aside className="sticky top-24 hidden w-72 shrink-0 xl:block">
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-card/90 p-4 shadow-sm">
|
||||
<Skeleton className="mb-4 h-6 w-36" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-10/12" />
|
||||
<Skeleton className="h-4 w-8/12" />
|
||||
<Skeleton className="h-4 w-9/12" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<article className="overflow-hidden rounded-[2.5rem] border border-border/70 bg-card/95">
|
||||
<header className="space-y-6 p-5 md:p-8">
|
||||
<Skeleton className="h-5 w-64" />
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-11/12" />
|
||||
<Skeleton className="h-12 w-8/12" />
|
||||
<Skeleton className="h-5 w-full max-w-3xl" />
|
||||
<Skeleton className="h-5 w-10/12 max-w-2xl" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Skeleton className="h-8 w-28 rounded-full" />
|
||||
<Skeleton className="h-8 w-36 rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-5 md:px-8">
|
||||
<Skeleton className="aspect-[16/9] w-full rounded-[2rem]" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-5 md:p-8 xl:hidden">
|
||||
<Skeleton className="h-32 w-full rounded-[1.5rem]" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 px-5 pb-8 pt-6 md:px-8 md:pb-10">
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
<Skeleton className="h-8 w-7/12" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-11/12" />
|
||||
<Skeleton className="h-4 w-9/12" />
|
||||
<Skeleton className="mt-8 h-8 w-6/12" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-10/12" />
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-4xl flex-wrap gap-2">
|
||||
<Skeleton className="h-8 w-20 rounded-full" />
|
||||
<Skeleton className="h-8 w-24 rounded-full" />
|
||||
<Skeleton className="h-8 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="mx-auto h-12 w-full max-w-4xl rounded-2xl" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Skeleton className="mt-8 h-48 w-full rounded-[2rem]" />
|
||||
<Skeleton className="mt-10 h-56 w-full rounded-[2rem]" />
|
||||
<Skeleton className="mt-10 h-72 w-full rounded-[2rem]" />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListingPageLoading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Skeleton className="mb-8 h-10 w-40" />
|
||||
<Skeleton className="mb-8 h-10 w-full max-w-md" />
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="overflow-hidden rounded-lg border bg-card"
|
||||
>
|
||||
<Skeleton className="aspect-video w-full rounded-none" />
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Skeleton className="h-6 flex-1" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="space-y-3 pt-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailPageLoading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<Skeleton className="h-10 w-36" />
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<Skeleton className="aspect-video w-full rounded-none" />
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-4/5" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-11/12" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-28 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import MobileVerificationGate from "@/components/MobileVerificationGate";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { NotificationsProvider } from "@/contexts/NotificationsContext";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = React.useState(() => new QueryClient());
|
||||
@@ -22,14 +20,11 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<NotificationsProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
{children}
|
||||
<MobileVerificationGate />
|
||||
</TooltipProvider>
|
||||
</NotificationsProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { UserProfileSchema } from '@/lib/types';
|
||||
|
||||
@@ -7,83 +7,47 @@ type User = UserProfileSchema;
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (identifier: string, password: string) => Promise<void>;
|
||||
loginWithOtp: (mobile: string, code: string) => Promise<void>;
|
||||
setSessionTokens: (accessToken: string, refreshToken: string) => Promise<void>;
|
||||
refreshProfile: () => Promise<User | null>;
|
||||
setUser: (user: User | null) => void;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'access_token';
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
setUser(null);
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const refreshProfile = useCallback(async () => {
|
||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return null;
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
try {
|
||||
const profile = await api.getProfile();
|
||||
setUser(profile as User);
|
||||
} catch (error) {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
try {
|
||||
const profile = await api.getProfile();
|
||||
setUser(profile as User);
|
||||
return profile as User;
|
||||
} catch {
|
||||
clearSession();
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clearSession]);
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await api.login({ email, password });
|
||||
localStorage.setItem('access_token', response.access_token);
|
||||
localStorage.setItem('refresh_token', response.refresh_token);
|
||||
await checkAuth();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshProfile();
|
||||
}, [refreshProfile]);
|
||||
|
||||
const setSessionTokens = useCallback(
|
||||
async (accessToken: string, refreshToken: string) => {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
setLoading(true);
|
||||
await refreshProfile();
|
||||
},
|
||||
[refreshProfile],
|
||||
);
|
||||
|
||||
const login = useCallback(
|
||||
async (identifier: string, password: string) => {
|
||||
const response = await api.login({ identifier, password });
|
||||
await setSessionTokens(response.access_token, response.refresh_token);
|
||||
},
|
||||
[setSessionTokens],
|
||||
);
|
||||
|
||||
const loginWithOtp = useCallback(
|
||||
async (mobile: string, code: string) => {
|
||||
const response = await api.loginWithOtp({ mobile, code });
|
||||
await setSessionTokens(response.access_token, response.refresh_token);
|
||||
},
|
||||
[setSessionTokens],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearSession();
|
||||
}, [clearSession]);
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -91,10 +55,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
loginWithOtp,
|
||||
setSessionTokens,
|
||||
refreshProfile,
|
||||
setUser,
|
||||
logout,
|
||||
isAuthenticated: !!user,
|
||||
}}
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
NotificationDeleteResponseSchema,
|
||||
NotificationListSchema,
|
||||
NotificationSchema,
|
||||
NotificationSeenResponseSchema,
|
||||
} from "@/lib/types";
|
||||
|
||||
type ConnectionStatus = "idle" | "connecting" | "connected" | "disconnected";
|
||||
|
||||
type NotificationsContextValue = {
|
||||
notifications: NotificationSchema[];
|
||||
unreadCount: number;
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean;
|
||||
connectionStatus: ConnectionStatus;
|
||||
refreshNotifications: () => Promise<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
markAsSeen: (notification: NotificationSchema) => Promise<NotificationSeenResponseSchema | null>;
|
||||
deleteNotification: (notification: NotificationSchema) => Promise<NotificationDeleteResponseSchema | null>;
|
||||
markAllAsSeen: () => Promise<void>;
|
||||
openNotification: (notification: NotificationSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
const NotificationsContext = createContext<NotificationsContextValue | undefined>(undefined);
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
const mergeNotifications = (
|
||||
current: NotificationSchema[],
|
||||
incoming: NotificationSchema[],
|
||||
) => {
|
||||
const byId = new Map<string, NotificationSchema>();
|
||||
for (const notification of current) {
|
||||
byId.set(notification.id, notification);
|
||||
}
|
||||
for (const notification of incoming) {
|
||||
const existing = byId.get(notification.id);
|
||||
byId.set(notification.id, existing ? { ...existing, ...notification } : notification);
|
||||
}
|
||||
return Array.from(byId.values()).sort((left, right) => {
|
||||
return new Date(right.created_at).getTime() - new Date(left.created_at).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
const openNotificationTarget = (notification: NotificationSchema) => {
|
||||
if (typeof window === "undefined" || !notification.action_url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = notification.action_url;
|
||||
if (/^https?:\/\//i.test(targetUrl)) {
|
||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
window.location.assign(targetUrl);
|
||||
};
|
||||
|
||||
export function NotificationsProvider({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [notifications, setNotifications] = useState<NotificationSchema[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("idle");
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const shownToastIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const hasMore = notifications.length < totalCount;
|
||||
|
||||
const cleanupStream = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
}, []);
|
||||
|
||||
const applyNotificationList = useCallback((payload: NotificationListSchema) => {
|
||||
setNotifications(payload.notifications);
|
||||
setUnreadCount(payload.unread_count);
|
||||
setTotalCount(payload.count);
|
||||
}, []);
|
||||
|
||||
const refreshNotifications = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
setTotalCount(0);
|
||||
setConnectionStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const payload = await api.getNotifications({ limit: PAGE_SIZE, offset: 0 });
|
||||
applyNotificationList(payload);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [applyNotificationList, isAuthenticated]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!isAuthenticated || isLoadingMore || !hasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const payload = await api.getNotifications({
|
||||
limit: PAGE_SIZE,
|
||||
offset: notifications.length,
|
||||
});
|
||||
setNotifications((current) => mergeNotifications(current, payload.notifications));
|
||||
setUnreadCount(payload.unread_count);
|
||||
setTotalCount(payload.count);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [hasMore, isAuthenticated, isLoadingMore, notifications.length]);
|
||||
|
||||
const markAsSeen = useCallback(async (notification: NotificationSchema) => {
|
||||
if (notification.is_seen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await api.markNotificationSeen(notification.id);
|
||||
if (payload.deleted) {
|
||||
setNotifications((current) => current.filter((item) => item.id !== notification.id));
|
||||
setTotalCount((current) => Math.max(current - 1, 0));
|
||||
} else {
|
||||
setNotifications((current) =>
|
||||
current.map((item) =>
|
||||
item.id === notification.id ? { ...item, is_seen: true } : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (typeof payload.unread_count === "number") {
|
||||
setUnreadCount(payload.unread_count);
|
||||
} else {
|
||||
setUnreadCount((current) => Math.max(current - 1, 0));
|
||||
}
|
||||
return payload;
|
||||
}, []);
|
||||
|
||||
const deleteNotification = useCallback(async (notification: NotificationSchema) => {
|
||||
const payload = await api.deleteNotification(notification.id);
|
||||
setNotifications((current) => current.filter((item) => item.id !== notification.id));
|
||||
setTotalCount((current) => Math.max(current - 1, 0));
|
||||
if (typeof payload.unread_count === "number") {
|
||||
setUnreadCount(payload.unread_count);
|
||||
} else if (!notification.is_seen) {
|
||||
setUnreadCount((current) => Math.max(current - 1, 0));
|
||||
}
|
||||
return payload;
|
||||
}, []);
|
||||
|
||||
const markAllAsSeen = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
await api.markAllNotificationsRead();
|
||||
setUnreadCount(0);
|
||||
setNotifications((current) =>
|
||||
current.map((notification) => ({ ...notification, is_seen: true })),
|
||||
);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const openNotification = useCallback(async (notification: NotificationSchema) => {
|
||||
await markAsSeen(notification);
|
||||
openNotificationTarget(notification);
|
||||
}, [markAsSeen]);
|
||||
|
||||
const announceIncomingNotification = useCallback((notification: NotificationSchema) => {
|
||||
if (notification.is_seen || shownToastIdsRef.current.has(notification.id)) {
|
||||
return;
|
||||
}
|
||||
shownToastIdsRef.current.add(notification.id);
|
||||
toast({
|
||||
title: notification.title,
|
||||
description: notification.message,
|
||||
variant:
|
||||
notification.level === "error"
|
||||
? "destructive"
|
||||
: notification.level === "success"
|
||||
? "success"
|
||||
: "default",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const connectToStream = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
cleanupStream();
|
||||
setConnectionStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupStream();
|
||||
setConnectionStatus("connecting");
|
||||
try {
|
||||
const tokenPayload = await api.issueNotificationStreamToken();
|
||||
const stream = new EventSource(api.buildNotificationStreamUrl(tokenPayload.token));
|
||||
eventSourceRef.current = stream;
|
||||
|
||||
stream.onopen = () => {
|
||||
reconnectAttemptRef.current = 0;
|
||||
setConnectionStatus("connected");
|
||||
};
|
||||
|
||||
stream.addEventListener("connected", (event) => {
|
||||
const payload = JSON.parse((event as MessageEvent<string>).data) as {
|
||||
notifications?: NotificationSchema[];
|
||||
unread_count?: number;
|
||||
};
|
||||
if (Array.isArray(payload.notifications)) {
|
||||
setNotifications((current) => mergeNotifications(current, payload.notifications || []));
|
||||
setTotalCount((current) => Math.max(current, payload.notifications?.length || 0));
|
||||
}
|
||||
if (typeof payload.unread_count === "number") {
|
||||
setUnreadCount(payload.unread_count);
|
||||
}
|
||||
});
|
||||
|
||||
stream.addEventListener("notification", (event) => {
|
||||
const payload = JSON.parse((event as MessageEvent<string>).data) as {
|
||||
notification?: NotificationSchema;
|
||||
unread_count?: number;
|
||||
};
|
||||
const incomingNotification = payload.notification;
|
||||
if (!incomingNotification) {
|
||||
return;
|
||||
}
|
||||
setNotifications((current) => {
|
||||
const exists = current.some((item) => item.id === incomingNotification.id);
|
||||
if (!exists) {
|
||||
setTotalCount((count) => count + 1);
|
||||
}
|
||||
return mergeNotifications(current, [incomingNotification]);
|
||||
});
|
||||
if (typeof payload.unread_count === "number") {
|
||||
setUnreadCount(payload.unread_count);
|
||||
}
|
||||
announceIncomingNotification(incomingNotification);
|
||||
});
|
||||
|
||||
stream.addEventListener("notification_seen", (event) => {
|
||||
const payload = JSON.parse((event as MessageEvent<string>).data) as {
|
||||
notification_id?: string;
|
||||
notification?: NotificationSchema | null;
|
||||
deleted?: boolean;
|
||||
unread_count?: number;
|
||||
};
|
||||
if (payload.deleted && payload.notification_id) {
|
||||
setNotifications((current) =>
|
||||
current.filter((item) => item.id !== payload.notification_id),
|
||||
);
|
||||
setTotalCount((current) => Math.max(current - 1, 0));
|
||||
} else if (payload.notification) {
|
||||
setNotifications((current) => mergeNotifications(current, [payload.notification!]));
|
||||
} else if (payload.notification_id) {
|
||||
setNotifications((current) =>
|
||||
current.map((item) =>
|
||||
item.id === payload.notification_id ? { ...item, is_seen: true } : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (typeof payload.unread_count === "number") {
|
||||
setUnreadCount(payload.unread_count);
|
||||
}
|
||||
});
|
||||
|
||||
stream.addEventListener("notification_mark_all_read", (event) => {
|
||||
const payload = JSON.parse((event as MessageEvent<string>).data) as {
|
||||
unread_count?: number;
|
||||
};
|
||||
setNotifications((current) =>
|
||||
current.map((notification) => ({ ...notification, is_seen: true })),
|
||||
);
|
||||
if (typeof payload.unread_count === "number") {
|
||||
setUnreadCount(payload.unread_count);
|
||||
}
|
||||
});
|
||||
|
||||
stream.addEventListener("unread_count", (event) => {
|
||||
const payload = JSON.parse((event as MessageEvent<string>).data) as {
|
||||
unread_count?: number;
|
||||
};
|
||||
if (typeof payload.unread_count === "number") {
|
||||
setUnreadCount(payload.unread_count);
|
||||
}
|
||||
});
|
||||
|
||||
stream.onerror = () => {
|
||||
stream.close();
|
||||
eventSourceRef.current = null;
|
||||
setConnectionStatus("disconnected");
|
||||
reconnectAttemptRef.current += 1;
|
||||
const delay = Math.min(1000 * 2 ** reconnectAttemptRef.current, 30000);
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
void connectToStream();
|
||||
}, delay);
|
||||
};
|
||||
} catch {
|
||||
setConnectionStatus("disconnected");
|
||||
}
|
||||
}, [announceIncomingNotification, cleanupStream, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
cleanupStream();
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
setTotalCount(0);
|
||||
setConnectionStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshNotifications();
|
||||
void connectToStream();
|
||||
|
||||
return () => {
|
||||
cleanupStream();
|
||||
};
|
||||
}, [cleanupStream, connectToStream, isAuthenticated, refreshNotifications]);
|
||||
|
||||
const value = useMemo<NotificationsContextValue>(() => ({
|
||||
notifications,
|
||||
unreadCount,
|
||||
totalCount,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
connectionStatus,
|
||||
refreshNotifications,
|
||||
loadMore,
|
||||
markAsSeen,
|
||||
deleteNotification,
|
||||
markAllAsSeen,
|
||||
openNotification,
|
||||
}), [
|
||||
connectionStatus,
|
||||
deleteNotification,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
markAllAsSeen,
|
||||
markAsSeen,
|
||||
notifications,
|
||||
openNotification,
|
||||
refreshNotifications,
|
||||
totalCount,
|
||||
unreadCount,
|
||||
]);
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const context = useContext(NotificationsContext);
|
||||
if (!context) {
|
||||
throw new Error("useNotifications must be used within a NotificationsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
font-family: 'Vazirmatn', sans-serif;
|
||||
}
|
||||
|
||||
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
|
||||
All colors MUST be HSL.
|
||||
*/
|
||||
@@ -53,7 +57,6 @@ All colors MUST be HSL.
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--route-progress: 199 89% 48%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -92,20 +95,15 @@ All colors MUST be HSL.
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--route-progress: 198 93% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background font-sans text-foreground;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
663
src/lib/api.ts
663
src/lib/api.ts
@@ -179,27 +179,6 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async loginWithOtp(data: Types.UserOtpLoginSchema) {
|
||||
return this.request<Types.TokenSchema>('/api/auth/login/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async sendOtp(data: Types.OtpSendSchema) {
|
||||
return this.request<Types.OtpSendResponseSchema>('/api/auth/otp/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyRegisterOtp(data: Types.RegisterOtpVerifySchema) {
|
||||
return this.request<Types.MessageSchema>('/api/auth/otp/verify-register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async refreshToken(data: Types.TokenRefreshIn) {
|
||||
return this.request<Types.TokenSchema>('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
@@ -207,60 +186,6 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async resetPassword(data: Types.PasswordResetSchema) {
|
||||
return this.request<Types.MessageSchema>('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async sendMobileVerificationOtp(data: Types.MobileOtpSendSchema) {
|
||||
return this.request<Types.OtpSendResponseSchema>('/api/auth/mobile/send-otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyMobile(data: Types.MobileOtpVerifySchema) {
|
||||
return this.request<Types.UserProfileSchema>('/api/auth/mobile/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async startGoogleLogin() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = `${this.baseUrl}/api/auth/oauth/google/start`;
|
||||
}
|
||||
}
|
||||
|
||||
async getGoogleFlow(flow: string) {
|
||||
return this.request<Types.GoogleFlowResponseSchema>(
|
||||
`/api/auth/oauth/google/flow?flow=${encodeURIComponent(flow)}`
|
||||
);
|
||||
}
|
||||
|
||||
async completeGoogleSignup(data: Types.GoogleCompleteSchema) {
|
||||
return this.request<Types.GoogleFlowResponseSchema>('/api/auth/oauth/google/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async resendGoogleClaimOtp(flow: string) {
|
||||
return this.request<Types.MessageSchema>('/api/auth/oauth/google/claim/send-otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ flow }),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyGoogleClaim(flow: string, code: string) {
|
||||
return this.request<Types.GoogleFlowResponseSchema>('/api/auth/oauth/google/claim/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ flow, code }),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyEmail(token: string): Promise<Types.MessageSchema> {
|
||||
const url = `${this.baseUrl}/api/auth/verify-email/${encodeURIComponent(token)}`;
|
||||
const response = await fetch(url, { method: 'GET' });
|
||||
@@ -340,17 +265,6 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getLegacyVerifyEmailMessage(token: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/auth/verify-email/${encodeURIComponent(token)}`);
|
||||
}
|
||||
|
||||
async getLegacyResetTokenMessage(token: string) {
|
||||
return this.request<Types.MessageSchema>('/api/auth/reset-password-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async checkUsername(username: string) {
|
||||
return this.request<Types.UsernameCheckSchema>(
|
||||
@@ -358,12 +272,6 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async checkMobile(mobile: string) {
|
||||
return this.request<Types.MobileLookupSchema>(
|
||||
`/api/auth/check-mobile?mobile=${encodeURIComponent(mobile)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Admin auth endpoints
|
||||
async listDeletedUsers() {
|
||||
return this.request<Types.UserProfileSchema[]>('/api/auth/users/deleted');
|
||||
@@ -397,283 +305,50 @@ class ApiClient {
|
||||
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async getUserDetail(userId: number) {
|
||||
return this.request<Types.UserProfileSchema>(`/api/auth/users/${userId}`);
|
||||
}
|
||||
|
||||
async listAuthorizationRoles() {
|
||||
return this.request<Types.AuthorizationRoleSchema[]>('/api/auth/roles');
|
||||
}
|
||||
|
||||
async getUserAuthorization(userId: number) {
|
||||
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`);
|
||||
}
|
||||
|
||||
async updateUserAuthorization(userId: number, data: Types.UserAuthorizationUpdateSchema) {
|
||||
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminDashboard(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
event_id?: number;
|
||||
granularity?: 'auto' | 'day' | 'week' | 'month';
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
if (params?.event_id != null) query.set('event_id', String(params.event_id));
|
||||
if (params?.granularity) query.set('granularity', params.granularity);
|
||||
return this.request<Types.AdminDashboardAnalyticsSchema>(
|
||||
`/api/analytics/admin/dashboard${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminUserAnalytics(params?: { date_from?: string; date_to?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
return this.request<Types.UserAnalyticsSchema>(
|
||||
`/api/analytics/admin/users${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminEventAnalytics(params?: { date_from?: string; date_to?: string; event_id?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
if (params?.event_id != null) query.set('event_id', String(params.event_id));
|
||||
return this.request<Types.EventAnalyticsSchema>(
|
||||
`/api/analytics/admin/events${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminBlogAnalytics(params?: { date_from?: string; date_to?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
return this.request<Types.BlogAnalyticsSchema>(
|
||||
`/api/analytics/admin/blog${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminDashboardEventOptions(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.AnalyticsEventOptionsSchema>(
|
||||
`/api/analytics/admin/events/options${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Blog Endpoints =============
|
||||
|
||||
async getPosts(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
tag?: string | string[];
|
||||
tag?: string;
|
||||
search?: string;
|
||||
featured?: boolean;
|
||||
author?: string | string[];
|
||||
author?: string;
|
||||
}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params?.category) queryParams.append('category', params.category);
|
||||
if (Array.isArray(params?.tag)) {
|
||||
params.tag.forEach((tag) => queryParams.append('tag', tag));
|
||||
} else if (params?.tag) {
|
||||
queryParams.append('tag', params.tag);
|
||||
}
|
||||
if (params?.tag) queryParams.append('tag', params.tag);
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.featured !== undefined) queryParams.append('featured', params.featured.toString());
|
||||
if (Array.isArray(params?.author)) {
|
||||
params.author.forEach((author) => queryParams.append('author', author));
|
||||
} else if (params?.author) {
|
||||
queryParams.append('author', params.author);
|
||||
}
|
||||
if (params?.author) queryParams.append('author', params.author);
|
||||
|
||||
const query = queryParams.toString();
|
||||
return this.request<Types.PostListSchema[]>(`/api/blog/posts${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async getBlogFilters() {
|
||||
return this.request<Types.BlogFiltersSchema>('/api/blog/filters');
|
||||
}
|
||||
|
||||
async getPost(slug: string) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`);
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`);
|
||||
}
|
||||
|
||||
async createPost(data: Types.PostCreateSchema) {
|
||||
return this.request<Types.PostDetailSchema>('/api/blog/admin/posts', {
|
||||
return this.request<Types.PostDetailSchema>('/api/blog/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updatePost(postId: number, data: Types.PostCreateSchema) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}`, {
|
||||
async updatePost(slug: string, data: Types.PostCreateSchema) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listAdminBlogPosts(params?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
mine?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.mine != null) query.set('mine', String(params.mine));
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PostListSchema[]>(`/api/blog/admin/posts${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async listBlogWriters() {
|
||||
return this.request<NonNullable<Types.PostListSchema['writers']>>('/api/blog/admin/writers');
|
||||
}
|
||||
|
||||
async getAdminBlogPost(postId: number) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}`);
|
||||
}
|
||||
|
||||
async submitBlogPost(postId: number) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/submit`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async reviewBlogPost(postId: number, data: Types.PostReviewSchema) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listBlogPostAssets(postId: number) {
|
||||
return this.request<Types.PostAssetSchema[]>(`/api/blog/admin/posts/${postId}/assets`);
|
||||
}
|
||||
|
||||
async uploadBlogPostFeaturedImage(postId: number, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/blog/admin/posts/${postId}/featured-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||
throw new Error(error.error || error.detail || 'Featured image upload failed');
|
||||
}
|
||||
|
||||
return response.json() as Promise<Types.PostDetailSchema>;
|
||||
}
|
||||
|
||||
async deleteBlogPostFeaturedImage(postId: number) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/featured-image`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async uploadBlogPostAsset(
|
||||
postId: number,
|
||||
file: File,
|
||||
data: { title?: string; alt_text?: string; caption?: string } = {},
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('title', data.title ?? '');
|
||||
formData.append('alt_text', data.alt_text ?? '');
|
||||
formData.append('caption', data.caption ?? '');
|
||||
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/blog/admin/posts/${postId}/assets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||
throw new Error(error.error || error.detail || 'Asset upload failed');
|
||||
}
|
||||
|
||||
return response.json() as Promise<Types.PostAssetSchema>;
|
||||
}
|
||||
|
||||
uploadBlogPostAssetWithProgress(
|
||||
postId: number,
|
||||
file: File,
|
||||
data: { title?: string; alt_text?: string; caption?: string } = {},
|
||||
onProgress?: (progress: number) => void,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('title', data.title ?? '');
|
||||
formData.append('alt_text', data.alt_text ?? '');
|
||||
formData.append('caption', data.caption ?? '');
|
||||
|
||||
const token = this.getStorageValue('access_token');
|
||||
|
||||
return new Promise<Types.PostAssetSchema>((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open('POST', `${this.baseUrl}/api/blog/admin/posts/${postId}/assets`);
|
||||
if (token) {
|
||||
request.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
request.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) return;
|
||||
onProgress?.(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
|
||||
request.onload = () => {
|
||||
let body: (Types.PostAssetSchema & ApiErrorBody) | null = null;
|
||||
try {
|
||||
body = request.responseText ? JSON.parse(request.responseText) as Types.PostAssetSchema & ApiErrorBody : null;
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (request.status >= 200 && request.status < 300 && body) {
|
||||
onProgress?.(100);
|
||||
resolve(body);
|
||||
return;
|
||||
}
|
||||
reject(new Error(body?.error || body?.detail || 'Asset upload failed'));
|
||||
};
|
||||
|
||||
request.onerror = () => reject(new Error('Asset upload failed'));
|
||||
request.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBlogPostAsset(postId: number, assetId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/admin/posts/${postId}/assets/${assetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async deletePost(slug: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`, {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -690,43 +365,16 @@ class ApiClient {
|
||||
|
||||
// Comments
|
||||
async getComments(slug: string) {
|
||||
return this.request<Types.CommentSchema[]>(`/api/blog/posts/${encodeURIComponent(slug)}/comments`);
|
||||
return this.request<Types.CommentSchema[]>(`/api/blog/posts/${slug}/comments`);
|
||||
}
|
||||
|
||||
async createComment(slug: string, data: Types.CommentCreateSchema) {
|
||||
return this.request<Types.CommentSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/comments`, {
|
||||
return this.request<Types.CommentSchema>(`/api/blog/posts/${slug}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateComment(commentId: number, data: Types.CommentUpdateSchema) {
|
||||
return this.request<Types.CommentSchema>(`/api/blog/comments/${commentId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async hideComment(commentId: number, note?: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/hide`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: note ?? '' }),
|
||||
});
|
||||
}
|
||||
|
||||
async unhideComment(commentId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/unhide`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteComment(commentId: number, note?: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: note ?? '' }),
|
||||
});
|
||||
}
|
||||
|
||||
async listDeletedComments() {
|
||||
return this.request<Types.CommentSchema[]>('/api/blog/deleted/comments');
|
||||
}
|
||||
@@ -739,27 +387,13 @@ class ApiClient {
|
||||
|
||||
// Likes
|
||||
async toggleLike(slug: string) {
|
||||
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/like`, {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/like`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async toggleSave(slug: string) {
|
||||
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/save`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async getBlogInteraction(slug: string) {
|
||||
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/interaction`);
|
||||
}
|
||||
|
||||
async getLikesCount(slug: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/likes`);
|
||||
}
|
||||
|
||||
async getMyBlogActivity() {
|
||||
return this.request<Types.BlogProfileActivitySchema>('/api/blog/me/activity');
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/likes`);
|
||||
}
|
||||
|
||||
// Categories
|
||||
@@ -767,30 +401,6 @@ class ApiClient {
|
||||
return this.request<Types.CategorySchema[]>('/api/blog/categories');
|
||||
}
|
||||
|
||||
async listAdminCategories() {
|
||||
return this.request<Types.AdminCategorySchema[]>('/api/blog/admin/categories');
|
||||
}
|
||||
|
||||
async createCategory(data: Types.CategoryWriteSchema) {
|
||||
return this.request<Types.AdminCategorySchema>('/api/blog/admin/categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateCategory(categoryId: number, data: Types.CategoryWriteSchema) {
|
||||
return this.request<Types.AdminCategorySchema>(`/api/blog/admin/categories/${categoryId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCategory(categoryId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/admin/categories/${categoryId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async getCategory(slug: string) {
|
||||
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
|
||||
}
|
||||
@@ -810,30 +420,6 @@ class ApiClient {
|
||||
return this.request<Types.TagSchema[]>('/api/blog/tags');
|
||||
}
|
||||
|
||||
async listAdminTags() {
|
||||
return this.request<Types.AdminTagSchema[]>('/api/blog/admin/tags');
|
||||
}
|
||||
|
||||
async createTag(data: Types.TagWriteSchema) {
|
||||
return this.request<Types.AdminTagSchema>('/api/blog/admin/tags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateTag(tagId: number, data: Types.TagWriteSchema) {
|
||||
return this.request<Types.AdminTagSchema>(`/api/blog/admin/tags/${tagId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTag(tagId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/admin/tags/${tagId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async getTag(slug: string) {
|
||||
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
|
||||
}
|
||||
@@ -914,68 +500,6 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async createEvent(data: Types.EventCreateSchema) {
|
||||
return this.request<Types.EventDetailSchema>('/api/events/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async uploadEventFeaturedImage(eventId: number, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/featured-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||
throw new Error(body.error || body.detail || 'Event image upload failed');
|
||||
}
|
||||
return response.json() as Promise<Types.EventDetailSchema>;
|
||||
}
|
||||
|
||||
async deleteEventFeaturedImage(eventId: number) {
|
||||
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}/featured-image`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async listEventGallery(eventId: number) {
|
||||
return this.request<Types.EventGalleryItem[]>(`/api/events/${eventId}/gallery`);
|
||||
}
|
||||
|
||||
async uploadEventGalleryImage(eventId: number, file: File, data: { title?: string; alt_text?: string } = {}) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (data.title) formData.append('title', data.title);
|
||||
if (data.alt_text) formData.append('alt_text', data.alt_text);
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/gallery`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||
throw new Error(body.error || body.detail || 'Event gallery upload failed');
|
||||
}
|
||||
return response.json() as Promise<Types.EventGalleryItem>;
|
||||
}
|
||||
|
||||
async deleteEventGalleryImage(eventId: number, imageId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/events/${eventId}/gallery/${imageId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEvent(eventId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -1091,44 +615,6 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async listDiscountCodes(params?: {
|
||||
search?: string;
|
||||
is_active?: boolean;
|
||||
type?: 'percent' | 'fixed';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.is_active != null) query.set('is_active', String(params.is_active));
|
||||
if (params?.type) query.set('type', params.type);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedDiscountCodeSchema>(
|
||||
`/api/payments/admin/discount-codes${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async createDiscountCode(data: Types.DiscountCodeWriteSchema) {
|
||||
return this.request<Types.DiscountCodeSchema>('/api/payments/admin/discount-codes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateDiscountCode(codeId: number, data: Types.DiscountCodeWriteSchema) {
|
||||
return this.request<Types.DiscountCodeSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDiscountCode(codeId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ============= Gallery Endpoints =============
|
||||
|
||||
async getGalleryImages(params?: {
|
||||
@@ -1177,86 +663,12 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getMajors(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||
const data = await this.getMajorsPaged({ limit: 100, offset: 0, ...params });
|
||||
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
|
||||
async getMajors(): Promise<Types.MajorOption[]> {
|
||||
return this.request('/api/meta/majors', { method: 'GET' });
|
||||
}
|
||||
|
||||
async getUniversities(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||
const data = await this.getUniversitiesPaged({ limit: 100, offset: 0, ...params });
|
||||
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
|
||||
}
|
||||
|
||||
async getMajorsPaged(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/majors${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async getUniversitiesPaged(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/universities${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async listAdminMajors(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/majors${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async createMajor(data: Types.MetaOptionWriteSchema) {
|
||||
return this.request<Types.MetaOptionSchema>('/api/meta/admin/majors', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateMajor(id: number, data: Types.MetaOptionWriteSchema) {
|
||||
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/majors/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMajor(id: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/meta/admin/majors/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async listAdminUniversities(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/universities${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async createUniversity(data: Types.MetaOptionWriteSchema) {
|
||||
return this.request<Types.MetaOptionSchema>('/api/meta/admin/universities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateUniversity(id: number, data: Types.MetaOptionWriteSchema) {
|
||||
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/universities/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUniversity(id: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/meta/admin/universities/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
async getUniversities(): Promise<Types.MajorOption[]> {
|
||||
return this.request('/api/meta/universities', { method: 'GET' });
|
||||
}
|
||||
|
||||
async subscribeNewsletter(email: string) {
|
||||
@@ -1269,47 +681,6 @@ class ApiClient {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getNotifications(params?: { limit?: number; offset?: number; type?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
if (params?.type) query.set('type', params.type);
|
||||
return this.request<Types.NotificationListSchema>(
|
||||
`/api/notifications/${query.toString() ? `?${query.toString()}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
async markNotificationSeen(id: string) {
|
||||
return this.request<Types.NotificationSeenResponseSchema>('/api/notifications/mark-seen', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteNotification(id: string) {
|
||||
return this.request<Types.NotificationDeleteResponseSchema>(`/api/notifications/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async markAllNotificationsRead(type?: string) {
|
||||
const query = type ? `?type=${encodeURIComponent(type)}` : '';
|
||||
return this.request<{ marked_read: number }>(`/api/notifications/mark-all-read${query}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async issueNotificationStreamToken() {
|
||||
return this.request<Types.NotificationStreamTokenResponseSchema>('/api/notifications/stream-token', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
buildNotificationStreamUrl(token: string) {
|
||||
const cleanBaseUrl = this.baseUrl.replace(/\/+$/, '');
|
||||
return `${cleanBaseUrl}/api/notifications/stream/?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/$/, "");
|
||||
|
||||
export function normalizeBlogSlugParam(slug: string) {
|
||||
try {
|
||||
return decodeURIComponent(slug);
|
||||
} catch {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
export function blogPostPath(slug: string) {
|
||||
return `/blog/${encodeURIComponent(normalizeBlogSlugParam(slug))}`;
|
||||
}
|
||||
|
||||
export function blogPostUrl(baseUrl: string, slug: string) {
|
||||
return `${trimTrailingSlash(baseUrl)}${blogPostPath(slug)}`;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
export type MarkdownHeading = {
|
||||
id: string;
|
||||
level: 1 | 2 | 3;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function plainHeadingText(value: string) {
|
||||
return value
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/\*([^*]+)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function headingIdBase(text: string) {
|
||||
const normalized = text
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.slice(0, 80);
|
||||
|
||||
return normalized || "section";
|
||||
}
|
||||
|
||||
export function extractMarkdownHeadings(content?: string): MarkdownHeading[] {
|
||||
const counters = new Map<string, number>();
|
||||
|
||||
return (content || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const match = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const level = match[1].length as 1 | 2 | 3;
|
||||
const text = plainHeadingText(match[2]);
|
||||
const base = headingIdBase(text);
|
||||
const nextCount = (counters.get(base) || 0) + 1;
|
||||
counters.set(base, nextCount);
|
||||
|
||||
return {
|
||||
id: nextCount === 1 ? base : `${base}-${nextCount}`,
|
||||
level,
|
||||
text,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as MarkdownHeading[];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
type ProgressEvent = "start" | "done";
|
||||
type Listener = (event: ProgressEvent) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
let active = false;
|
||||
|
||||
function emit(event: ProgressEvent) {
|
||||
listeners.forEach((listener) => listener(event));
|
||||
}
|
||||
|
||||
export function subscribeNavigationProgress(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function startNavigationProgress() {
|
||||
if (active) {
|
||||
return;
|
||||
}
|
||||
|
||||
active = true;
|
||||
emit("start");
|
||||
}
|
||||
|
||||
export function completeNavigationProgress() {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
active = false;
|
||||
emit("done");
|
||||
}
|
||||
@@ -54,15 +54,11 @@ async function requestJson<T>(
|
||||
options?: {
|
||||
params?: Record<string, QueryValue>;
|
||||
revalidate?: number;
|
||||
cache?: RequestCache;
|
||||
},
|
||||
) {
|
||||
const response = await fetch(
|
||||
buildUrl(path, options?.params),
|
||||
options?.cache
|
||||
? { cache: options.cache }
|
||||
: { next: { revalidate: options?.revalidate ?? DEFAULT_REVALIDATE_SECONDS } },
|
||||
);
|
||||
const response = await fetch(buildUrl(path, options?.params), {
|
||||
next: { revalidate: options?.revalidate ?? DEFAULT_REVALIDATE_SECONDS },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new PublicApiError(path, response.status);
|
||||
@@ -71,56 +67,20 @@ async function requestJson<T>(
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function getPublicPosts(options?: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
tag?: string[];
|
||||
author?: string[];
|
||||
limit?: number;
|
||||
}) {
|
||||
export async function getPublicPosts(options?: { search?: string; limit?: number }) {
|
||||
const search = options?.search?.trim();
|
||||
const category = options?.category?.trim();
|
||||
const tag = options?.tag?.filter(Boolean) ?? [];
|
||||
const author = options?.author?.filter(Boolean) ?? [];
|
||||
|
||||
return requestJson<Types.PostListSchema[]>("/api/blog/posts", {
|
||||
params: {
|
||||
limit: options?.limit ?? 50,
|
||||
...(search ? { search } : {}),
|
||||
...(category ? { category } : {}),
|
||||
...(tag.length ? { tag } : {}),
|
||||
...(author.length ? { author } : {}),
|
||||
},
|
||||
revalidate: search || category || tag.length || author.length ? 60 : DEFAULT_REVALIDATE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBlogFilters() {
|
||||
return requestJson<Types.BlogFiltersSchema>("/api/blog/filters", {
|
||||
revalidate: DEFAULT_REVALIDATE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBlogBanners() {
|
||||
return requestJson<Types.BlogBannerSchema[]>("/api/blog/banners", {
|
||||
revalidate: DEFAULT_REVALIDATE_SECONDS,
|
||||
revalidate: search ? 60 : DEFAULT_REVALIDATE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPublicPost(slug: string) {
|
||||
return requestJson<Types.PostDetailSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRecommendedPosts(slug: string, limit = 3) {
|
||||
return requestJson<Types.PostListSchema[]>(
|
||||
`/api/blog/posts/${encodeURIComponent(slug)}/recommended`,
|
||||
{
|
||||
params: { limit },
|
||||
revalidate: DEFAULT_REVALIDATE_SECONDS,
|
||||
},
|
||||
);
|
||||
return requestJson<Types.PostDetailSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
|
||||
export async function getPublicEvents(options?: { search?: string; limit?: number }) {
|
||||
|
||||
@@ -7,10 +7,6 @@ import {
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "next/navigation";
|
||||
import {
|
||||
completeNavigationProgress,
|
||||
startNavigationProgress,
|
||||
} from "@/lib/navigation-progress";
|
||||
|
||||
type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
to: string;
|
||||
@@ -30,53 +26,9 @@ type NavLinkProps = Omit<LinkProps, "className"> & {
|
||||
className?: string | ((state: { isActive: boolean }) => string);
|
||||
};
|
||||
|
||||
function isPlainLeftClick(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
export function Link({ to, replace, prefetch, children, ...props }: LinkProps) {
|
||||
return (
|
||||
event.button === 0 &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
);
|
||||
}
|
||||
|
||||
function shouldTrackNavigation(to: string) {
|
||||
if (typeof window === "undefined") {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const current = new URL(window.location.href);
|
||||
const target = new URL(to, window.location.href);
|
||||
return current.origin === target.origin && current.pathname !== target.pathname;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function Link({ to, replace, prefetch, children, onClick, target, ...props }: LinkProps) {
|
||||
return (
|
||||
<NextLink
|
||||
href={to}
|
||||
replace={replace}
|
||||
prefetch={prefetch}
|
||||
target={target}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
target === "_blank" ||
|
||||
!isPlainLeftClick(event) ||
|
||||
!shouldTrackNavigation(to)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
startNavigationProgress();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<NextLink href={to} replace={replace} prefetch={prefetch} {...props}>
|
||||
{children}
|
||||
</NextLink>
|
||||
);
|
||||
@@ -117,7 +69,6 @@ export function useNavigate(): NavigateFunction {
|
||||
return React.useCallback(
|
||||
(to: string | number, options?: { replace?: boolean }) => {
|
||||
if (typeof to === "number") {
|
||||
startNavigationProgress();
|
||||
if (to === -1) {
|
||||
router.back();
|
||||
return;
|
||||
@@ -128,12 +79,6 @@ export function useNavigate(): NavigateFunction {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldTrackNavigation(to)) {
|
||||
startNavigationProgress();
|
||||
} else {
|
||||
completeNavigationProgress();
|
||||
}
|
||||
|
||||
if (options?.replace) {
|
||||
router.replace(to);
|
||||
return;
|
||||
|
||||
640
src/lib/types.ts
640
src/lib/types.ts
@@ -12,45 +12,20 @@ export interface ErrorSchema {
|
||||
export interface TokenSchema {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
export interface MajorOption {
|
||||
code: string;
|
||||
label: string;
|
||||
id?: number;
|
||||
name?: string;
|
||||
user_count?: number;
|
||||
}
|
||||
|
||||
export interface MetaOptionSchema {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
label: string;
|
||||
user_count?: number;
|
||||
}
|
||||
|
||||
export interface PagedMetaOptionSchema {
|
||||
count: number;
|
||||
results: MetaOptionSchema[];
|
||||
}
|
||||
|
||||
export interface MetaOptionWriteSchema {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserProfileSchema {
|
||||
id: number;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
email: string;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
profile_picture?: string;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
bio?: string;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number;
|
||||
@@ -59,84 +34,35 @@ export interface UserProfileSchema {
|
||||
date_joined: string;
|
||||
|
||||
is_email_verified?: boolean;
|
||||
is_mobile_verified?: boolean;
|
||||
requires_mobile_verification?: boolean;
|
||||
has_google_link?: boolean;
|
||||
is_active?: boolean;
|
||||
is_staff?: boolean;
|
||||
is_superuser?: boolean;
|
||||
is_committee?: boolean;
|
||||
is_deleted?: boolean;
|
||||
deleted_at?: string | null;
|
||||
can_access_blog_admin?: boolean;
|
||||
can_write_blog_posts?: boolean;
|
||||
can_review_blog_posts?: boolean;
|
||||
}
|
||||
|
||||
export interface UserListSchema {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
full_name?: string | null;
|
||||
university?: string | null;
|
||||
major?: string | null;
|
||||
profile_picture?: string | null;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number | null;
|
||||
bio?: string | null;
|
||||
is_email_verified?: boolean;
|
||||
is_mobile_verified?: boolean;
|
||||
is_deleted?: boolean;
|
||||
deleted_at?: string | null;
|
||||
can_access_blog_admin?: boolean;
|
||||
can_write_blog_posts?: boolean;
|
||||
can_review_blog_posts?: boolean;
|
||||
is_active: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
date_joined: string;
|
||||
}
|
||||
|
||||
export interface AuthorizationRoleSchema {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface UserAuthorizationSchema {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
is_active: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
groups: string[];
|
||||
roles: AuthorizationRoleSchema[];
|
||||
}
|
||||
|
||||
export interface UserAuthorizationUpdateSchema {
|
||||
is_staff: boolean;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface UserRegistrationSchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number | null;
|
||||
major?: string | null;
|
||||
@@ -144,7 +70,6 @@ export interface UserRegistrationSchema {
|
||||
}
|
||||
|
||||
export type UserUpdateSchema = {
|
||||
email?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
bio?: string | null;
|
||||
@@ -156,123 +81,25 @@ export type UserUpdateSchema = {
|
||||
|
||||
|
||||
export interface UserLoginSchema {
|
||||
identifier: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserOtpLoginSchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface RegisterOtpVerifySchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface OtpSendSchema {
|
||||
mobile: string;
|
||||
mode: "register" | "login" | "reset_password" | "verify_mobile" | "google_claim";
|
||||
}
|
||||
|
||||
export interface OtpSendResponseSchema {
|
||||
message: string;
|
||||
expires_in_seconds: number;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface TokenRefreshIn {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface UsernameCheckSchema {
|
||||
exists: boolean;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface MobileLookupSchema {
|
||||
exists: boolean;
|
||||
has_password: boolean;
|
||||
export interface PasswordResetRequestSchema {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetSchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface MobileOtpSendSchema {
|
||||
mobile: string;
|
||||
}
|
||||
|
||||
export interface MobileOtpVerifySchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface GoogleFlowResponseSchema {
|
||||
status: "authenticated" | "collect_profile" | "claim_required" | "error";
|
||||
email?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
avatar_url?: string | null;
|
||||
resolution?: "new_account" | "existing_email_claim" | "existing_mobile_claim" | null;
|
||||
mobile?: string | null;
|
||||
mobile_hint?: string | null;
|
||||
detail?: string | null;
|
||||
access_token?: string | null;
|
||||
refresh_token?: string | null;
|
||||
}
|
||||
|
||||
export interface GoogleCompleteSchema {
|
||||
flow: string;
|
||||
mobile: string;
|
||||
username?: string | null;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number | null;
|
||||
major?: string | null;
|
||||
university?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationSchema {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
level: "info" | "success" | "warning" | "error";
|
||||
created_at: string;
|
||||
is_seen: boolean;
|
||||
delete_on_seen: boolean;
|
||||
action_url?: string | null;
|
||||
entity_type?: string | null;
|
||||
entity_id?: string | number | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NotificationListSchema {
|
||||
count: number;
|
||||
unread_count: number;
|
||||
notifications: NotificationSchema[];
|
||||
}
|
||||
|
||||
export interface NotificationSeenResponseSchema {
|
||||
marked_read: boolean;
|
||||
notification_id?: string | null;
|
||||
deleted?: boolean;
|
||||
notification?: NotificationSchema | null;
|
||||
unread_count?: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationDeleteResponseSchema {
|
||||
deleted: boolean;
|
||||
notification_id?: string | null;
|
||||
unread_count?: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationStreamTokenResponseSchema {
|
||||
export interface PasswordResetConfirmSchema {
|
||||
token: string;
|
||||
expires_in: number;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Blog Types
|
||||
@@ -283,40 +110,19 @@ export interface PostListSchema {
|
||||
excerpt?: string;
|
||||
featured_image?: string;
|
||||
absolute_featured_image_url?: string | null;
|
||||
absolute_featured_image_thumbnail_url?: string | null;
|
||||
absolute_featured_image_preview_url?: string | null;
|
||||
author: {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
bio?: string | null;
|
||||
profile_picture?: string;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
};
|
||||
category?: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
parent_id?: number | null;
|
||||
};
|
||||
category_path?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}>;
|
||||
writers?: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
bio?: string | null;
|
||||
profile_picture?: string;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
}>;
|
||||
tags: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -327,72 +133,24 @@ export interface PostListSchema {
|
||||
created_at: string;
|
||||
is_featured: boolean;
|
||||
reading_time?: number;
|
||||
updated_at: string;
|
||||
seo_title?: string;
|
||||
seo_description?: string;
|
||||
canonical_url?: string;
|
||||
og_title?: string;
|
||||
og_description?: string;
|
||||
noindex?: boolean;
|
||||
focus_keyword?: string;
|
||||
likes_count?: number;
|
||||
saves_count?: number;
|
||||
comments_count?: number;
|
||||
}
|
||||
|
||||
export interface PostDetailSchema extends PostListSchema {
|
||||
content: string;
|
||||
content_html?: string;
|
||||
review_note?: string;
|
||||
og_image_url?: string | null;
|
||||
updated_at: string;
|
||||
views_count?: number;
|
||||
assets?: PostAssetSchema[];
|
||||
}
|
||||
|
||||
export interface PostCreateSchema {
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
category_id?: number | null;
|
||||
summary: string;
|
||||
category_id?: number;
|
||||
tag_ids?: number[];
|
||||
writer_ids?: number[];
|
||||
featured_image?: string;
|
||||
is_featured?: boolean;
|
||||
status?: 'draft' | 'submitted' | 'changes_requested' | 'published' | 'archived';
|
||||
seo_title?: string;
|
||||
seo_description?: string;
|
||||
canonical_url?: string;
|
||||
og_title?: string;
|
||||
og_description?: string;
|
||||
noindex?: boolean;
|
||||
focus_keyword?: string;
|
||||
}
|
||||
|
||||
export interface PostReviewSchema {
|
||||
action: 'publish' | 'approve' | 'request_changes' | 'changes_requested' | 'archive';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PostAssetSchema {
|
||||
id: number;
|
||||
file_type: 'image' | 'video' | 'document' | 'archive' | 'other';
|
||||
title: string;
|
||||
alt_text?: string;
|
||||
caption?: string;
|
||||
size: number;
|
||||
mime_type?: string;
|
||||
created_at: string;
|
||||
absolute_file_url?: string | null;
|
||||
absolute_thumbnail_url?: string | null;
|
||||
absolute_preview_url?: string | null;
|
||||
absolute_blur_url?: string | null;
|
||||
markdown_image?: string | null;
|
||||
markdown_link?: string | null;
|
||||
uploaded_by: {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
};
|
||||
status?: 'draft' | 'published';
|
||||
}
|
||||
|
||||
export interface CommentSchema {
|
||||
@@ -403,24 +161,13 @@ export interface CommentSchema {
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
bio?: string | null;
|
||||
profile_picture?: string | null;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
};
|
||||
post_id: number;
|
||||
post_title: string;
|
||||
post_slug: string;
|
||||
parent_id?: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
is_approved: boolean;
|
||||
is_hidden?: boolean;
|
||||
is_deleted?: boolean;
|
||||
hidden_at?: string | null;
|
||||
deleted_at?: string | null;
|
||||
hidden_replies_count?: number;
|
||||
replies?: CommentSchema[];
|
||||
}
|
||||
|
||||
export interface CommentCreateSchema {
|
||||
@@ -428,54 +175,14 @@ export interface CommentCreateSchema {
|
||||
parent_id?: number;
|
||||
}
|
||||
|
||||
export interface CommentUpdateSchema {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BlogBannerSchema {
|
||||
id: number;
|
||||
title?: string;
|
||||
alt_text?: string;
|
||||
image_url: string;
|
||||
url: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface BlogInteractionSchema {
|
||||
liked: boolean;
|
||||
saved: boolean;
|
||||
likes_count: number;
|
||||
saves_count: number;
|
||||
comments_count: number;
|
||||
}
|
||||
|
||||
export interface BlogProfileActivitySchema {
|
||||
liked_posts: PostListSchema[];
|
||||
saved_posts: PostListSchema[];
|
||||
comments: CommentSchema[];
|
||||
replies: CommentSchema[];
|
||||
}
|
||||
|
||||
export interface CategorySchema {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
parent_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminCategorySchema extends CategorySchema {
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
export interface CategoryWriteSchema {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
parent_id?: number | null;
|
||||
}
|
||||
|
||||
export interface TagSchema {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -483,45 +190,6 @@ export interface TagSchema {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminTagSchema extends TagSchema {
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
export interface TagWriteSchema {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
}
|
||||
|
||||
export interface BlogFilterCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
parent_id?: number | null;
|
||||
post_count: number;
|
||||
children: BlogFilterCategory[];
|
||||
}
|
||||
|
||||
export interface BlogFilterTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
export interface BlogFilterAuthor {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
export interface BlogFiltersSchema {
|
||||
categories: BlogFilterCategory[];
|
||||
tags: BlogFilterTag[];
|
||||
authors: BlogFilterAuthor[];
|
||||
}
|
||||
|
||||
// Events Types
|
||||
export interface EventListItemSchema {
|
||||
id: number;
|
||||
@@ -530,8 +198,6 @@ export interface EventListItemSchema {
|
||||
description: string;
|
||||
featured_image?: string | null;
|
||||
absolute_featured_image_url?: string | null;
|
||||
absolute_featured_image_thumbnail_url?: string | null;
|
||||
absolute_featured_image_preview_url?: string | null;
|
||||
event_type: 'online' | 'on_site' | 'hybrid';
|
||||
address?: string | null;
|
||||
location?: string | null;
|
||||
@@ -554,16 +220,8 @@ export interface EventGalleryItem {
|
||||
title: string;
|
||||
description: string;
|
||||
absolute_image_url?: string | null;
|
||||
absolute_image_preview_url?: string | null;
|
||||
absolute_image_blur_url?: string | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
file_size_mb?: number;
|
||||
markdown_url?: string;
|
||||
image?: string;
|
||||
alt_text?: string | null;
|
||||
is_public?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface EventDetailSchema extends EventListItemSchema {
|
||||
@@ -576,28 +234,19 @@ export interface EventDetailSchema extends EventListItemSchema {
|
||||
export interface EventCreateSchema {
|
||||
title: string;
|
||||
description: string;
|
||||
slug?: string | null;
|
||||
event_type: 'online' | 'on_site' | 'hybrid';
|
||||
address?: string | null;
|
||||
location?: string | null;
|
||||
online_link?: string | null;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
registration_start_date?: string | null;
|
||||
registration_end_date?: string | null;
|
||||
capacity?: number | null;
|
||||
price?: number | null;
|
||||
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||
registration_success_markdown?: string | null;
|
||||
gallery_image_ids?: number[] | null;
|
||||
start_date: string;
|
||||
end_date?: string;
|
||||
location: string;
|
||||
capacity?: number;
|
||||
event_image?: string;
|
||||
requirements?: string;
|
||||
is_registration_open?: boolean;
|
||||
}
|
||||
|
||||
export interface PaymentAdminSchema {
|
||||
id: number;
|
||||
authority?: string | null;
|
||||
ref_id?: string | null;
|
||||
card_pan?: string | null;
|
||||
card_hash?: string | null;
|
||||
status: number;
|
||||
status_label: string;
|
||||
base_amount: number;
|
||||
@@ -622,14 +271,6 @@ export interface RegistrationAdminSchema {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
profile_picture?: string | null;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
university?: string | null;
|
||||
major?: string | null;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number | null;
|
||||
};
|
||||
payments: PaymentAdminSchema[];
|
||||
}
|
||||
@@ -639,7 +280,6 @@ export interface EventAdminDetailSchema extends EventDetailSchema {
|
||||
}
|
||||
export interface EventUpdateSchema {
|
||||
title?: string;
|
||||
slug?: string | null;
|
||||
description?: string;
|
||||
event_type?: 'online' | 'on_site' | 'hybrid';
|
||||
address?: string | null;
|
||||
@@ -652,47 +292,9 @@ export interface EventUpdateSchema {
|
||||
capacity?: number | null;
|
||||
price?: number | null;
|
||||
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||
registration_success_markdown?: string | null;
|
||||
gallery_image_ids?: number[] | null;
|
||||
}
|
||||
|
||||
export interface DiscountCodeSchema {
|
||||
id: number;
|
||||
code: string;
|
||||
type: 'percent' | 'fixed';
|
||||
value: number;
|
||||
max_discount?: number | null;
|
||||
is_active: boolean;
|
||||
starts_at?: string | null;
|
||||
ends_at?: string | null;
|
||||
usage_limit_total?: number | null;
|
||||
usage_limit_per_user?: number | null;
|
||||
min_amount?: number | null;
|
||||
applicable_event_ids: number[];
|
||||
usage_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PagedDiscountCodeSchema {
|
||||
count: number;
|
||||
results: DiscountCodeSchema[];
|
||||
}
|
||||
|
||||
export interface DiscountCodeWriteSchema {
|
||||
code: string;
|
||||
type: 'percent' | 'fixed';
|
||||
value: number;
|
||||
max_discount?: number | null;
|
||||
is_active?: boolean;
|
||||
starts_at?: string | null;
|
||||
ends_at?: string | null;
|
||||
usage_limit_total?: number | null;
|
||||
usage_limit_per_user?: number | null;
|
||||
min_amount?: number | null;
|
||||
applicable_event_ids?: number[];
|
||||
}
|
||||
|
||||
export interface EventRegistrationSchema {
|
||||
id: number;
|
||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
||||
@@ -726,11 +328,6 @@ export interface GalleryImageSchema {
|
||||
title: string;
|
||||
description?: string;
|
||||
image: string;
|
||||
absolute_image_url?: string | null;
|
||||
absolute_image_preview_url?: string | null;
|
||||
absolute_image_blur_url?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
uploaded_by: {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -753,197 +350,6 @@ export interface PaginatedResponse<T> {
|
||||
previous?: string;
|
||||
}
|
||||
|
||||
// Admin analytics
|
||||
export interface AnalyticsPointSchema {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPointGroupSchema {
|
||||
items: AnalyticsPointSchema[];
|
||||
top_items: AnalyticsPointSchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTrendPointSchema {
|
||||
date: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsRegistrationStatusSchema {
|
||||
status: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTopEventSchema {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
attendees: number;
|
||||
capacity?: number | null;
|
||||
fill_rate?: number | null;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPostPopularitySchema {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
likes: number;
|
||||
saves: number;
|
||||
comments: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPostPopularityGroupSchema {
|
||||
items: AnalyticsPostPopularitySchema[];
|
||||
top_items: AnalyticsPostPopularitySchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTopPostSchema extends AnalyticsPostPopularitySchema {
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
event_id?: number | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
};
|
||||
summary: {
|
||||
total_users: number;
|
||||
verified_users: number;
|
||||
total_events: number;
|
||||
total_registrations: number;
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
published_posts: number;
|
||||
total_likes: number;
|
||||
total_saves: number;
|
||||
total_comments: number;
|
||||
};
|
||||
users: {
|
||||
signup_trend: AnalyticsTrendPointSchema[];
|
||||
by_major: AnalyticsPointSchema[];
|
||||
by_university: AnalyticsPointSchema[];
|
||||
by_year: AnalyticsPointSchema[];
|
||||
};
|
||||
events: {
|
||||
registration_status: AnalyticsRegistrationStatusSchema[];
|
||||
by_major: AnalyticsPointSchema[];
|
||||
by_university: AnalyticsPointSchema[];
|
||||
top_events: AnalyticsTopEventSchema[];
|
||||
registration_trend: AnalyticsTrendPointSchema[];
|
||||
};
|
||||
revenue: {
|
||||
trend: AnalyticsTrendPointSchema[];
|
||||
by_event: AnalyticsPointSchema[];
|
||||
payment_status: AnalyticsRegistrationStatusSchema[];
|
||||
total_paid: number;
|
||||
total_discount: number;
|
||||
total_base: number;
|
||||
};
|
||||
blog: {
|
||||
totals: {
|
||||
posts: number;
|
||||
likes: number;
|
||||
saves: number;
|
||||
comments: number;
|
||||
};
|
||||
post_popularity: AnalyticsPostPopularitySchema[];
|
||||
top_posts: AnalyticsTopPostSchema[];
|
||||
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
|
||||
by_category: AnalyticsPointSchema[];
|
||||
by_tag: AnalyticsPointSchema[];
|
||||
};
|
||||
achievements: {
|
||||
distinct_participants: number;
|
||||
learning_hours: number;
|
||||
published_content: number;
|
||||
community_engagement: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyticsEventOptionsSchema {
|
||||
count: number;
|
||||
results: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UserAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
};
|
||||
summary: {
|
||||
total_users: number;
|
||||
verified_users: number;
|
||||
unverified_users: number;
|
||||
profile_completion_rate: number;
|
||||
};
|
||||
signup_trend: AnalyticsTrendPointSchema[];
|
||||
by_major: AnalyticsPointGroupSchema;
|
||||
by_university: AnalyticsPointGroupSchema;
|
||||
by_year: AnalyticsPointGroupSchema;
|
||||
}
|
||||
|
||||
export interface EventAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
event_id?: number | null;
|
||||
};
|
||||
summary: {
|
||||
total_events: number;
|
||||
total_registrations: number;
|
||||
distinct_participants: number;
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
total_base: number;
|
||||
learning_hours: number;
|
||||
};
|
||||
registration_status: AnalyticsRegistrationStatusSchema[];
|
||||
payment_status: AnalyticsRegistrationStatusSchema[];
|
||||
attendee_by_major: AnalyticsPointGroupSchema;
|
||||
attendee_by_university: AnalyticsPointGroupSchema;
|
||||
registration_trend: AnalyticsTrendPointSchema[];
|
||||
revenue_trend: AnalyticsTrendPointSchema[];
|
||||
revenue_by_event: AnalyticsPointGroupSchema;
|
||||
top_events: {
|
||||
top_items: AnalyticsTopEventSchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BlogAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
};
|
||||
summary: {
|
||||
published_posts: number;
|
||||
total_likes: number;
|
||||
total_saves: number;
|
||||
total_comments: number;
|
||||
community_engagement: number;
|
||||
};
|
||||
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
|
||||
post_popularity: AnalyticsPostPopularityGroupSchema;
|
||||
top_posts: AnalyticsTopPostSchema[];
|
||||
by_category: AnalyticsPointGroupSchema;
|
||||
by_tag: AnalyticsPointGroupSchema;
|
||||
}
|
||||
|
||||
// payment
|
||||
export interface CreatePaymentOut {
|
||||
start_pay_url: string;
|
||||
|
||||
104
src/lib/utils.ts
104
src/lib/utils.ts
@@ -35,105 +35,11 @@ export function formatJalali(iso?: string, withTime: boolean = true): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function formatJalaliDate(iso?: string): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
const locale = Intl.DateTimeFormat.supportedLocalesOf(['fa-IR-u-ca-persian']).length > 0
|
||||
? 'fa-IR-u-ca-persian'
|
||||
: 'fa-IR';
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_THUMB = "/placeholder.svg";
|
||||
|
||||
const pickFirstUrl = (...values: Array<string | null | undefined>) =>
|
||||
values.find((value) => Boolean(value)) || DEFAULT_THUMB;
|
||||
|
||||
export const getEventCardImageUrl = (event: Types.EventListItemSchema) =>
|
||||
pickFirstUrl(
|
||||
event.absolute_featured_image_thumbnail_url,
|
||||
event.absolute_featured_image_preview_url,
|
||||
event.absolute_featured_image_url,
|
||||
event.featured_image,
|
||||
);
|
||||
|
||||
export const getEventHeroImageUrl = (event: Types.EventListItemSchema) =>
|
||||
pickFirstUrl(
|
||||
event.absolute_featured_image_preview_url,
|
||||
event.absolute_featured_image_url,
|
||||
event.absolute_featured_image_thumbnail_url,
|
||||
event.featured_image,
|
||||
);
|
||||
|
||||
export const getEventSeoImageUrl = (event: Types.EventListItemSchema) =>
|
||||
pickFirstUrl(
|
||||
event.absolute_featured_image_url,
|
||||
event.absolute_featured_image_preview_url,
|
||||
event.absolute_featured_image_thumbnail_url,
|
||||
event.featured_image,
|
||||
);
|
||||
|
||||
export const getThumbUrl = getEventCardImageUrl;
|
||||
|
||||
export const getBlogCardImageUrl = (post: Types.PostListSchema) =>
|
||||
[
|
||||
post.absolute_featured_image_thumbnail_url,
|
||||
post.absolute_featured_image_preview_url,
|
||||
post.absolute_featured_image_url,
|
||||
post.featured_image,
|
||||
].find((value) => Boolean(value));
|
||||
|
||||
export const getBlogHeroImageUrl = (post: Types.PostListSchema) =>
|
||||
[
|
||||
post.absolute_featured_image_preview_url,
|
||||
post.absolute_featured_image_url,
|
||||
post.absolute_featured_image_thumbnail_url,
|
||||
post.featured_image,
|
||||
].find((value) => Boolean(value));
|
||||
|
||||
export const getGalleryImagePreviewUrl = (
|
||||
image:
|
||||
| Types.EventGalleryItem
|
||||
| Types.GalleryImageSchema,
|
||||
) =>
|
||||
pickFirstUrl(
|
||||
image.absolute_image_preview_url,
|
||||
image.absolute_image_url,
|
||||
"image" in image ? image.image : undefined,
|
||||
);
|
||||
|
||||
export const getGalleryImageBlurUrl = (
|
||||
image:
|
||||
| Types.EventGalleryItem
|
||||
| Types.GalleryImageSchema,
|
||||
) =>
|
||||
pickFirstUrl(
|
||||
image.absolute_image_blur_url,
|
||||
image.absolute_image_preview_url,
|
||||
image.absolute_image_url,
|
||||
"image" in image ? image.image : undefined,
|
||||
);
|
||||
|
||||
export const getGalleryImageFullUrl = (
|
||||
image:
|
||||
| Types.EventGalleryItem
|
||||
| Types.GalleryImageSchema,
|
||||
) =>
|
||||
pickFirstUrl(
|
||||
image.absolute_image_url,
|
||||
image.absolute_image_preview_url,
|
||||
image.absolute_image_blur_url,
|
||||
"image" in image ? image.image : undefined,
|
||||
);
|
||||
const DEFAULT_THUMB = '/placeholder.svg';
|
||||
export const getThumbUrl = (e: Types.EventListItemSchema) =>
|
||||
e.absolute_featured_image_url ||
|
||||
e.featured_image ||
|
||||
DEFAULT_THUMB;
|
||||
|
||||
const PERSIAN_DIGITS = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
|
||||
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, Search, ShieldCheck, UserCog } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { cn, resolveErrorMessage } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const normalizeSearchDigits = (value: string) =>
|
||||
value
|
||||
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||||
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)));
|
||||
|
||||
function fullName(user: Pick<Types.UserListSchema, "first_name" | "last_name" | "username">) {
|
||||
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
||||
}
|
||||
|
||||
export default function AdminAuthorizations() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
const [draftAuth, setDraftAuth] = useState<Types.UserAuthorizationUpdateSchema | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["admin", "authorizations", "users", search],
|
||||
queryFn: () => api.listUsers({ search: search || undefined, limit: PAGE_SIZE, offset: 0 }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setSearch(normalizeSearchDigits(searchDraft).trim());
|
||||
}, 400);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [searchDraft]);
|
||||
|
||||
const authQuery = useQuery({
|
||||
queryKey: ["admin", "authorizations", selectedUserId],
|
||||
queryFn: () => api.getUserAuthorization(selectedUserId as number),
|
||||
enabled: Boolean(selectedUserId),
|
||||
});
|
||||
|
||||
const selectedAuth = authQuery.data;
|
||||
const isSelf = Boolean(selectedAuth && user?.id === selectedAuth.id);
|
||||
const effectiveDraft = draftAuth ?? (selectedAuth ? {
|
||||
is_staff: selectedAuth.is_staff,
|
||||
groups: selectedAuth.groups.filter((group) => ["blog_editor", "blog_supervisor", "association_admin"].includes(group)),
|
||||
} : null);
|
||||
|
||||
const selectUser = async (target: Types.UserListSchema) => {
|
||||
setSelectedUserId(target.id);
|
||||
setDraftAuth(null);
|
||||
};
|
||||
|
||||
const toggleGroup = (group: string, checked: boolean) => {
|
||||
if (!effectiveDraft || isSelf) return;
|
||||
setDraftAuth({
|
||||
...effectiveDraft,
|
||||
groups: checked
|
||||
? Array.from(new Set([...effectiveDraft.groups, group]))
|
||||
: effectiveDraft.groups.filter((item) => item !== group),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStaff = (checked: boolean) => {
|
||||
if (!effectiveDraft || isSelf) return;
|
||||
setDraftAuth({ ...effectiveDraft, is_staff: checked });
|
||||
};
|
||||
|
||||
const saveAuthorization = async () => {
|
||||
if (!selectedUserId || !effectiveDraft || isSelf) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const updated = await api.updateUserAuthorization(selectedUserId, effectiveDraft);
|
||||
setDraftAuth({
|
||||
is_staff: updated.is_staff,
|
||||
groups: updated.groups.filter((group) => ["blog_editor", "blog_supervisor", "association_admin"].includes(group)),
|
||||
});
|
||||
toast({ title: "دسترسی کاربر بهروزرسانی شد", variant: "success" });
|
||||
await usersQuery.refetch();
|
||||
await authQuery.refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "ذخیره دسترسی ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-right">
|
||||
<h2 className="text-2xl font-bold">مدیریت دسترسیها</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">تخصیص نقشهای امن و آماده به کاربران. مجوزهای مستقیم Django از این صفحه قابل تغییر نیستند.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[380px_1fr]">
|
||||
<Card className="h-fit">
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>جستجوی کاربر</CardTitle>
|
||||
<CardDescription>نام، موبایل، ایمیل یا نام کاربری را جستجو کنید.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4" dir="ltr">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchDraft}
|
||||
onChange={(event) => setSearchDraft(event.target.value)}
|
||||
placeholder="جستجو..."
|
||||
className="pl-10 text-right"
|
||||
/>
|
||||
</div>
|
||||
{usersQuery.isLoading ? (
|
||||
<div className="flex justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : usersQuery.data?.length ? (
|
||||
<div className="space-y-2">
|
||||
{usersQuery.data.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => void selectUser(item)}
|
||||
className={cn(
|
||||
"w-full rounded-2xl border p-3 text-right transition hover:bg-muted/40",
|
||||
selectedUserId === item.id ? "border-primary bg-primary/10" : "border-border/70 bg-background",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant={item.is_superuser ? "default" : item.is_staff ? "secondary" : "outline"}>
|
||||
{item.is_superuser ? "سوپریوزر" : item.is_staff ? "staff" : "کاربر"}
|
||||
</Badge>
|
||||
<span className="font-medium">{fullName(item)}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
<p dir="ltr" className="text-right">{item.mobile || "بدون شماره موبایل"}</p>
|
||||
<p>{item.email || item.username}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed p-6 text-center text-sm text-muted-foreground">کاربری یافت نشد.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCog className="h-5 w-5 text-primary" />
|
||||
<CardTitle>نقشهای کاربر</CardTitle>
|
||||
</div>
|
||||
<CardDescription>فقط نقشهای آماده قابل تغییر هستند؛ سوپریوزر خواندنی است.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedUserId ? (
|
||||
<div className="rounded-3xl border border-dashed p-10 text-center text-sm text-muted-foreground">یک کاربر را از لیست انتخاب کنید.</div>
|
||||
) : authQuery.isLoading || !selectedAuth || !effectiveDraft ? (
|
||||
<div className="flex justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-3xl border bg-muted/20 p-4 text-right">
|
||||
<p className="text-lg font-bold">{fullName(selectedAuth)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{selectedAuth.mobile || selectedAuth.email || selectedAuth.username}</p>
|
||||
{isSelf ? (
|
||||
<p className="mt-3 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
برای جلوگیری از قفل شدن حساب، نقشهای کاربر فعلی از این صفحه قابل تغییر نیست.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3" dir="ltr">
|
||||
{selectedAuth.roles.map((role) => {
|
||||
const isStaffRole = role.key === "staff_admin";
|
||||
const isSuperuserRole = role.key === "is_superuser";
|
||||
const checked = isStaffRole
|
||||
? effectiveDraft.is_staff
|
||||
: isSuperuserRole
|
||||
? selectedAuth.is_superuser
|
||||
: effectiveDraft.groups.includes(role.key);
|
||||
const disabled = role.locked || isSelf || saving;
|
||||
return (
|
||||
<div key={role.key} className="flex items-center justify-between gap-4 rounded-2xl border p-4">
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(value) => {
|
||||
if (isStaffRole) toggleStaff(value);
|
||||
else if (!isSuperuserRole) toggleGroup(role.key, value);
|
||||
}}
|
||||
/>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{role.locked ? <ShieldCheck className="h-4 w-4 text-primary" /> : null}
|
||||
<p className="font-medium">{role.label}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{role.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start gap-2">
|
||||
<Button variant="outline" onClick={() => setDraftAuth(null)} disabled={saving || isSelf}>
|
||||
بازنشانی تغییرات
|
||||
</Button>
|
||||
<Button onClick={saveAuthorization} disabled={saving || isSelf}>
|
||||
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
|
||||
ذخیره دسترسیها
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { BookOpenText, CheckCircle2, Clock3, Edit, Eye, Loader2, Plus, Send, XCircle } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import BlogThumbnail from "@/components/BlogThumbnail";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { formatJalali, resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "پیشنویس",
|
||||
submitted: "در انتظار بررسی",
|
||||
changes_requested: "نیازمند اصلاح",
|
||||
published: "منتشر شده",
|
||||
archived: "آرشیو شده",
|
||||
};
|
||||
|
||||
export default function AdminBlog() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [posts, setPosts] = useState<Types.PostListSchema[]>([]);
|
||||
const [status, setStatus] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actingId, setActingId] = useState<number | null>(null);
|
||||
const [changesPost, setChangesPost] = useState<Types.PostListSchema | null>(null);
|
||||
const [changesNote, setChangesNote] = useState("");
|
||||
|
||||
const canReview = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
||||
|
||||
const loadPosts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.listAdminBlogPosts({
|
||||
status: status === "all" ? undefined : status,
|
||||
search: search.trim() || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
setPosts(data);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "دریافت نوشتهها ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, status, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return posts.reduce<Record<string, number>>((acc, post) => {
|
||||
acc[post.status] = (acc[post.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}, [posts]);
|
||||
|
||||
const submitPost = async (postId: number) => {
|
||||
setActingId(postId);
|
||||
try {
|
||||
await api.submitBlogPost(postId);
|
||||
await loadPosts();
|
||||
toast({ title: "نوشته برای بررسی ارسال شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "ارسال ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const reviewPost = async (postId: number, action: Types.PostReviewSchema["action"], note?: string) => {
|
||||
setActingId(postId);
|
||||
try {
|
||||
await api.reviewBlogPost(postId, { action, note });
|
||||
await loadPosts();
|
||||
toast({ title: action === "publish" ? "نوشته منتشر شد" : "درخواست اصلاح ثبت شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "عملیات ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openChangesDialog = (post: Types.PostListSchema) => {
|
||||
setChangesPost(post);
|
||||
setChangesNote("");
|
||||
};
|
||||
|
||||
const closeChangesDialog = () => {
|
||||
if (actingId) return;
|
||||
setChangesPost(null);
|
||||
setChangesNote("");
|
||||
};
|
||||
|
||||
const requestChanges = async () => {
|
||||
if (!changesPost) return;
|
||||
await reviewPost(changesPost.id, "request_changes", changesNote.trim() || undefined);
|
||||
setChangesPost(null);
|
||||
setChangesNote("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-right">
|
||||
<h2 className="text-2xl font-bold">مدیریت بلاگ</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
پیشنویسها، صف بررسی، انتشار و اصلاح نوشتهها.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to="/admin/blog/new/edit">
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
نوشته جدید
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><BookOpenText className="h-5 w-5 text-primary" /><span>کل: {posts.length}</span></CardContent></Card>
|
||||
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><Clock3 className="h-5 w-5 text-amber-600" /><span>بررسی: {stats.submitted ?? 0}</span></CardContent></Card>
|
||||
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><CheckCircle2 className="h-5 w-5 text-emerald-600" /><span>منتشر: {stats.published ?? 0}</span></CardContent></Card>
|
||||
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><XCircle className="h-5 w-5 text-rose-600" /><span>اصلاح: {stats.changes_requested ?? 0}</span></CardContent></Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-right">
|
||||
<CardTitle>نوشتهها</CardTitle>
|
||||
<CardDescription>دسترسی نویسندهها به نوشتههای خودشان محدود میشود.</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو..." className="w-full text-right sm:w-64" />
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-full sm:w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه وضعیتها</SelectItem>
|
||||
<SelectItem value="draft">پیشنویس</SelectItem>
|
||||
<SelectItem value="submitted">در انتظار بررسی</SelectItem>
|
||||
<SelectItem value="changes_requested">نیازمند اصلاح</SelectItem>
|
||||
<SelectItem value="published">منتشر شده</SelectItem>
|
||||
<SelectItem value="archived">آرشیو شده</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
||||
) : posts.length ? (
|
||||
posts.map((post) => (
|
||||
<div key={post.id} className="flex flex-col gap-4 rounded-2xl border p-3 sm:p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3 md:gap-4">
|
||||
<BlogThumbnail
|
||||
post={post}
|
||||
imageUrl={post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image}
|
||||
className="h-20 w-24 shrink-0 rounded-xl sm:h-24 sm:w-36 md:h-28 md:w-44"
|
||||
imageClassName="group-hover:scale-100"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<div className="flex flex-col-reverse flex-wrap items-start gap-2 sm:flex-row sm:items-center">
|
||||
<h3 className="line-clamp-2 font-semibold leading-7">{post.title}</h3>
|
||||
<Badge variant={post.status === "published" ? "default" : "secondary"}>{statusLabels[post.status] ?? post.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{post.updated_at ? formatJalali(post.updated_at, false) : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 md:grid md:grid-cols-2 md:grid-rows-2" dir="ltr">
|
||||
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-1 md:row-start-1 md:block md:basis-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" asChild className="w-full">
|
||||
<Link
|
||||
to={`/admin/blog/${post.id}/preview`}
|
||||
aria-label="پیشنمایش"
|
||||
className="flex justify-center"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>پیشنمایش</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-1 md:row-start-2 md:block md:basis-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="secondary" size="sm" asChild className="w-full">
|
||||
<Link
|
||||
to={`/admin/blog/${post.id}/edit`}
|
||||
aria-label="ویرایش"
|
||||
className="flex justify-center"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>ویرایش</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{post.status === "draft" || post.status === "changes_requested" ? (
|
||||
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-1 md:block md:basis-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => submitPost(post.id)}
|
||||
disabled={actingId === post.id}
|
||||
aria-label="ارسال برای بررسی"
|
||||
className="w-full"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>ارسال برای بررسی</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canReview && post.status === "submitted" ? (
|
||||
<>
|
||||
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-1 md:block md:basis-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => reviewPost(post.id, "publish")}
|
||||
disabled={actingId === post.id}
|
||||
aria-label="انتشار"
|
||||
className="w-full"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>انتشار</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-2 md:block md:basis-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openChangesDialog(post)}
|
||||
disabled={actingId === post.id}
|
||||
aria-label="درخواست اصلاح"
|
||||
className="w-full"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>درخواست اصلاح</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed p-8 text-center text-muted-foreground">
|
||||
نوشتهای پیدا نشد.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={Boolean(changesPost)} onOpenChange={(open) => (open ? undefined : closeChangesDialog())}>
|
||||
<DialogContent className="max-w-xl rounded-3xl" dir="rtl">
|
||||
<DialogHeader className="mt-6 text-right md:text-right">
|
||||
<DialogTitle>درخواست اصلاح نوشته</DialogTitle>
|
||||
<DialogDescription>
|
||||
توضیح کوتاهی بنویسید تا نویسنده بداند چه چیزی باید در نوشته اصلاح شود.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-2xl border bg-muted/30 p-3 text-right">
|
||||
<p className="text-xs text-muted-foreground">نوشته</p>
|
||||
<p className="mt-1 font-semibold">{changesPost?.title}</p>
|
||||
</div>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void requestChanges();
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
value={changesNote}
|
||||
onChange={(event) => setChangesNote(event.target.value)}
|
||||
placeholder="مثلاً: بخش مقدمه نیاز به منبع دارد، تیترها را واضحتر کنید، یا نمونه کد را اصلاح کنید..."
|
||||
className="min-h-36 text-right leading-7"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-wrap justify-start gap-2">
|
||||
<Button type="submit" disabled={!changesPost || actingId === changesPost.id}>
|
||||
{actingId === changesPost?.id ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
|
||||
ثبت درخواست اصلاح
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={closeChangesDialog} disabled={Boolean(actingId)}>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
Video,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { cn, resolveErrorMessage } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type Props = {
|
||||
postId: number;
|
||||
};
|
||||
|
||||
type QueueStatus = "queued" | "uploading" | "uploaded" | "failed";
|
||||
|
||||
type QueueItem = {
|
||||
id: string;
|
||||
file: File;
|
||||
progress: number;
|
||||
status: QueueStatus;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const formatSize = (size: number) => {
|
||||
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const fileKindFromAsset = (asset: Types.PostAssetSchema) => asset.file_type;
|
||||
|
||||
const fileKindFromFile = (file: File): Types.PostAssetSchema["file_type"] => {
|
||||
if (file.type.startsWith("image/")) return "image";
|
||||
if (file.type.startsWith("video/")) return "video";
|
||||
if (file.type.includes("zip") || /\.(zip|rar|7z|tar|gz)$/i.test(file.name)) return "archive";
|
||||
if (file.type || /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|md)$/i.test(file.name)) return "document";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const iconForKind = (kind: Types.PostAssetSchema["file_type"]) => {
|
||||
if (kind === "image") return ImageIcon;
|
||||
if (kind === "video") return Video;
|
||||
if (kind === "document") return FileText;
|
||||
if (kind === "archive") return FileArchive;
|
||||
return File;
|
||||
};
|
||||
|
||||
const statusLabel: Record<QueueStatus, string> = {
|
||||
queued: "در صف",
|
||||
uploading: "در حال آپلود",
|
||||
uploaded: "آپلود شد",
|
||||
failed: "ناموفق",
|
||||
};
|
||||
|
||||
export default function AdminBlogAssets({ postId }: Props) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const processingRef = useRef(false);
|
||||
const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
|
||||
const [assets, setAssets] = useState<Types.PostAssetSchema[]>([]);
|
||||
const [queue, setQueue] = useState<QueueItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [previewAsset, setPreviewAsset] = useState<Types.PostAssetSchema | null>(null);
|
||||
|
||||
const hasActiveUpload = queue.some((item) => item.status === "uploading");
|
||||
const queueSummary = useMemo(() => {
|
||||
const uploaded = queue.filter((item) => item.status === "uploaded").length;
|
||||
const failed = queue.filter((item) => item.status === "failed").length;
|
||||
return { uploaded, failed, total: queue.length };
|
||||
}, [queue]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!Number.isFinite(postId)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [postData, assetData] = await Promise.all([
|
||||
api.getAdminBlogPost(postId),
|
||||
api.listBlogPostAssets(postId),
|
||||
]);
|
||||
setPost(postData);
|
||||
setAssets(assetData);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "دریافت مرکز آپلود ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [postId]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextItem = queue.find((item) => item.status === "queued");
|
||||
if (!nextItem || processingRef.current) return;
|
||||
|
||||
processingRef.current = true;
|
||||
setQueue((current) =>
|
||||
current.map((item) => (item.id === nextItem.id ? { ...item, status: "uploading", progress: 1 } : item)),
|
||||
);
|
||||
|
||||
api.uploadBlogPostAssetWithProgress(
|
||||
postId,
|
||||
nextItem.file,
|
||||
{ title: nextItem.file.name },
|
||||
(progress) => {
|
||||
setQueue((current) =>
|
||||
current.map((item) => (item.id === nextItem.id ? { ...item, progress: Math.max(progress, item.progress) } : item)),
|
||||
);
|
||||
},
|
||||
)
|
||||
.then((asset) => {
|
||||
setAssets((current) => [asset, ...current]);
|
||||
setQueue((current) =>
|
||||
current.map((item) => (item.id === nextItem.id ? { ...item, status: "uploaded", progress: 100 } : item)),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setQueue((current) =>
|
||||
current.map((item) =>
|
||||
item.id === nextItem.id
|
||||
? { ...item, status: "failed", error: resolveErrorMessage(error, "آپلود ناموفق بود") }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
processingRef.current = false;
|
||||
setQueue((current) => [...current]);
|
||||
});
|
||||
}, [postId, queue]);
|
||||
|
||||
const addFilesToQueue = (files: FileList | File[]) => {
|
||||
const nextFiles = Array.from(files);
|
||||
if (!nextFiles.length) return;
|
||||
setQueue((current) => [
|
||||
...current,
|
||||
...nextFiles.map((file) => ({
|
||||
id: `${file.name}-${file.size}-${file.lastModified}-${crypto.randomUUID()}`,
|
||||
file,
|
||||
progress: 0,
|
||||
status: "queued" as const,
|
||||
})),
|
||||
]);
|
||||
};
|
||||
|
||||
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files) addFilesToQueue(event.target.files);
|
||||
event.currentTarget.value = "";
|
||||
};
|
||||
|
||||
const removeQueueItem = (itemId: string) => {
|
||||
setQueue((current) => current.filter((item) => item.id !== itemId || item.status === "uploading"));
|
||||
};
|
||||
|
||||
const clearFinishedQueue = () => {
|
||||
setQueue((current) => current.filter((item) => item.status === "queued" || item.status === "uploading"));
|
||||
};
|
||||
|
||||
const copySnippet = async (asset: Types.PostAssetSchema) => {
|
||||
const snippet = asset.markdown_image || asset.markdown_link || asset.absolute_file_url || "";
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
toast({ title: "کد مارکداون کپی شد", variant: "success" });
|
||||
};
|
||||
|
||||
const deleteAsset = async (assetId: number) => {
|
||||
setDeletingId(assetId);
|
||||
try {
|
||||
await api.deleteBlogPostAsset(postId, assetId);
|
||||
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
|
||||
toast({ title: "فایل حذف شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "حذف فایل ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openAssetPreview = (asset: Types.PostAssetSchema) => {
|
||||
const fileUrl = asset.absolute_file_url;
|
||||
if (asset.file_type === "image" || asset.file_type === "video") {
|
||||
setPreviewAsset(asset);
|
||||
return;
|
||||
}
|
||||
if (fileUrl) window.open(fileUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center" dir="rtl">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-right">
|
||||
<h2 className="text-2xl font-bold">مرکز آپلود نوشته</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{post?.title ? `فایلهای عمومی مرتبط با «${post.title}»` : "فایلهای عمومی این نوشته را مدیریت کنید."}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => router.push(`/admin/blog/${postId}/edit`)}>
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
بازگشت به ویرایش
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="space-y-5 p-4 md:p-6">
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onFileChange} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex min-h-48 w-full flex-col items-center justify-center rounded-2xl border border-dashed bg-muted/20 p-6 text-center transition hover:bg-muted/40"
|
||||
>
|
||||
<UploadCloud className="mb-3 h-12 w-12 text-muted-foreground" />
|
||||
<span className="font-semibold">افزودن فایلهای بیشتر</span>
|
||||
<span className="mt-1 text-sm text-muted-foreground">چند فایل را همزمان انتخاب کنید؛ فایلها یکییکی آپلود میشوند.</span>
|
||||
</button>
|
||||
|
||||
{queue.length ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2 text-right text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="outline" size="sm" onClick={clearFinishedQueue} disabled={hasActiveUpload}>
|
||||
پاکسازی فایلهای تمامشده
|
||||
</Button>
|
||||
<span>
|
||||
{queueSummary.total} فایل · {queueSummary.uploaded} موفق · {queueSummary.failed} ناموفق
|
||||
</span>
|
||||
</div>
|
||||
{queue.map((item) => {
|
||||
const kind = fileKindFromFile(item.file);
|
||||
const Icon = iconForKind(kind);
|
||||
return (
|
||||
<div key={item.id} className="rounded-2xl border bg-background p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground"
|
||||
disabled={item.status === "uploading"}
|
||||
onClick={() => removeQueueItem(item.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<p className="truncate text-sm font-medium">{item.file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatSize(item.file.size)}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={item.status === "failed" ? "destructive" : item.status === "uploaded" ? "default" : "secondary"}
|
||||
className="shrink-0"
|
||||
>
|
||||
{statusLabel[item.status]}
|
||||
</Badge>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{item.status === "uploaded" ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : null}
|
||||
{item.status === "failed" ? <AlertCircle className="h-4 w-4 text-destructive" /> : null}
|
||||
<Progress value={item.progress} className="h-2" />
|
||||
</div>
|
||||
{item.error ? <p className="mt-2 text-right text-xs text-destructive">{item.error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{assets.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{assets.map((asset) => {
|
||||
const kind = fileKindFromAsset(asset);
|
||||
const Icon = iconForKind(kind);
|
||||
const previewUrl = asset.absolute_thumbnail_url || asset.absolute_preview_url || asset.absolute_file_url;
|
||||
return (
|
||||
<div key={asset.id} className="rounded-2xl border bg-background p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openAssetPreview(asset)}
|
||||
className={cn(
|
||||
"flex aspect-video w-full items-center justify-center overflow-hidden rounded-xl bg-muted text-muted-foreground",
|
||||
asset.absolute_file_url ? "cursor-pointer hover:bg-muted/70" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
{kind === "image" && previewUrl ? (
|
||||
<img src={previewUrl} alt={asset.alt_text || asset.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<Icon className="h-10 w-10" />
|
||||
)}
|
||||
</button>
|
||||
<div className="mt-3 flex items-start gap-3">
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => copySnippet(asset)} aria-label="کپی مارکداون">
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{asset.absolute_file_url ? (
|
||||
<Button variant="ghost" size="icon" asChild aria-label="باز کردن فایل">
|
||||
<a href={asset.absolute_file_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<ConfirmAction
|
||||
title="حذف فایل"
|
||||
description={`آیا از حذف فایل «${asset.title}» مطمئن هستید؟ لینکهای استفادهشده از این فایل دیگر کار نخواهند کرد.`}
|
||||
onConfirm={() => deleteAsset(asset.id)}
|
||||
disabled={deletingId === asset.id}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={deletingId === asset.id}
|
||||
aria-label="حذف فایل"
|
||||
>
|
||||
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Badge variant="secondary">{asset.file_type}</Badge>
|
||||
<p className="truncate font-medium">{asset.title}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{asset.mime_type || "file"} · {formatSize(asset.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
هنوز فایلی برای این نوشته آپلود نشده است.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={Boolean(previewAsset)} onOpenChange={(open) => !open && setPreviewAsset(null)}>
|
||||
<DialogContent className="max-w-4xl" dir="rtl">
|
||||
<DialogHeader className="text-right">
|
||||
<DialogTitle>{previewAsset?.title}</DialogTitle>
|
||||
<DialogDescription>{previewAsset?.caption || previewAsset?.mime_type || "پیشنمایش فایل"}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{previewAsset?.file_type === "image" ? (
|
||||
<img
|
||||
src={previewAsset.absolute_preview_url || previewAsset.absolute_file_url || ""}
|
||||
alt={previewAsset.alt_text || previewAsset.title}
|
||||
className="max-h-[75vh] w-full rounded-2xl object-contain"
|
||||
/>
|
||||
) : null}
|
||||
{previewAsset?.file_type === "video" && previewAsset.absolute_file_url ? (
|
||||
<video src={previewAsset.absolute_file_url} className="max-h-[75vh] w-full rounded-2xl" controls />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type CategoryForm = {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
parent_id: string;
|
||||
};
|
||||
|
||||
const emptyForm: CategoryForm = {
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
parent_id: "none",
|
||||
};
|
||||
|
||||
export default function AdminBlogCategories() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [search, setSearch] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Types.AdminCategorySchema | null>(null);
|
||||
const [form, setForm] = useState<CategoryForm>(emptyForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const canDelete = Boolean(user?.is_superuser);
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ["admin", "blog", "categories"],
|
||||
queryFn: () => api.listAdminCategories(),
|
||||
});
|
||||
|
||||
const deletedQuery = useQuery({
|
||||
queryKey: ["admin", "blog", "categories", "deleted"],
|
||||
queryFn: () => api.listDeletedCategories(),
|
||||
enabled: canDelete,
|
||||
});
|
||||
|
||||
const categories = useMemo(() => categoriesQuery.data ?? [], [categoriesQuery.data]);
|
||||
const rootCategories = useMemo(() => categories.filter((category) => !category.parent_id), [categories]);
|
||||
const editingHasChildren = useMemo(
|
||||
() => Boolean(editing && categories.some((category) => category.parent_id === editing.id)),
|
||||
[categories, editing],
|
||||
);
|
||||
const visibleCategories = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return categories;
|
||||
return categories.filter((category) =>
|
||||
[category.name, category.slug, category.description ?? ""].some((value) => value.toLowerCase().includes(needle)),
|
||||
);
|
||||
}, [categories, search]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (category: Types.AdminCategorySchema) => {
|
||||
setEditing(category);
|
||||
setForm({
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description ?? "",
|
||||
parent_id: category.parent_id ? String(category.parent_id) : "none",
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = (force = false) => {
|
||||
if (submitting && !force) return;
|
||||
setDialogOpen(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const saveCategory = async () => {
|
||||
const payload: Types.CategoryWriteSchema = {
|
||||
name: form.name.trim(),
|
||||
slug: form.slug.trim() || null,
|
||||
description: form.description,
|
||||
parent_id: form.parent_id === "none" ? null : Number(form.parent_id),
|
||||
};
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await api.updateCategory(editing.id, payload);
|
||||
} else {
|
||||
await api.createCategory(payload);
|
||||
}
|
||||
toast({ title: editing ? "دستهبندی ویرایش شد" : "دستهبندی ساخته شد", variant: "success" });
|
||||
await categoriesQuery.refetch();
|
||||
closeDialog(true);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "ذخیره دستهبندی ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCategory = async (category: Types.AdminCategorySchema) => {
|
||||
try {
|
||||
await api.deleteCategory(category.id);
|
||||
toast({ title: "دستهبندی حذف شد", variant: "success" });
|
||||
await Promise.all([categoriesQuery.refetch(), deletedQuery.refetch()]);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "حذف دستهبندی ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const restoreCategory = async (category: Types.CategorySchema) => {
|
||||
try {
|
||||
await api.restoreCategory(category.id);
|
||||
toast({ title: "دستهبندی بازیابی شد", variant: "success" });
|
||||
await Promise.all([categoriesQuery.refetch(), deletedQuery.refetch()]);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "بازیابی دستهبندی ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-right">
|
||||
<h2 className="text-2xl font-bold">دستهبندیهای بلاگ</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">ساخت و مدیریت دستهبندیهای تو در تو برای نوشتهها.</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
دستهبندی جدید
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>لیست دستهبندیها</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو در نام، اسلاگ یا توضیح..." className="text-right" />
|
||||
{categoriesQuery.isLoading ? (
|
||||
<div className="flex justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : visibleCategories.length ? (
|
||||
<div className="overflow-x-auto rounded-2xl border">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-right">عنوان</th>
|
||||
{/* <th className="px-4 py-3 text-right">اسلاگ</th> */}
|
||||
<th className="px-4 py-3 text-right">والد</th>
|
||||
<th className="px-4 py-3 text-right">تعداد نوشته</th>
|
||||
<th className="px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleCategories.map((category) => (
|
||||
<tr key={category.id} className="border-t">
|
||||
<td className="px-4 py-3 font-medium">{category.name}</td>
|
||||
{/* <td className="px-4 py-3 text-muted-foreground">{category.slug}</td> */}
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{categories.find((item) => item.id === category.parent_id)?.name ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">{toPersianDigits(String(category.post_count))}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(category)}>
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<ConfirmAction
|
||||
title="حذف دستهبندی"
|
||||
description={`آیا از حذف دستهبندی «${category.name}» مطمئن هستید؟`}
|
||||
onConfirm={() => deleteCategory(category)}
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">دستهبندیای یافت نشد.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canDelete ? (
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>دستهبندیهای حذفشده</CardTitle>
|
||||
<CardDescription>بازیابی رکوردهای حذف شده.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{deletedQuery.data?.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{deletedQuery.data.map((category) => (
|
||||
<div key={category.id} className="flex items-center justify-between rounded-2xl border p-3">
|
||||
<span className="font-medium">{category.name}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => void restoreCategory(category)}>
|
||||
<RotateCcw className="ml-1 h-3.5 w-3.5" />
|
||||
بازیابی
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">مورد حذفشدهای وجود ندارد.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent dir="rtl" className="text-right">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "ویرایش دستهبندی" : "دستهبندی جدید"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">نام</Label>
|
||||
<Input value={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">اسلاگ اختیاری</Label>
|
||||
<Input value={form.slug} onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))} dir="ltr" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">والد</Label>
|
||||
<Select
|
||||
value={form.parent_id}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, parent_id: value }))}
|
||||
disabled={editingHasChildren}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">بدون والد</SelectItem>
|
||||
{rootCategories.filter((item) => item.id !== editing?.id).map((category) => (
|
||||
<SelectItem key={category.id} value={String(category.id)}>{category.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{editingHasChildren ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
دستهبندیهایی که زیرمجموعه دارند باید در سطح ریشه باقی بمانند.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">توضیحات</Label>
|
||||
<Textarea value={form.description} onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))} className="min-h-24" />
|
||||
</div>
|
||||
<div className="flex justify-start gap-2">
|
||||
<Button variant="outline" onClick={() => closeDialog()} disabled={submitting}>انصراف</Button>
|
||||
<Button onClick={saveCategory} disabled={submitting || !form.name.trim()}>
|
||||
{submitting ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import MarkdownEditor, { type MarkdownDirectionMode } from "@/components/MarkdownEditor";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type Props = {
|
||||
postId: number | null;
|
||||
};
|
||||
|
||||
const emptyForm: Types.PostCreateSchema = {
|
||||
title: "",
|
||||
content: "",
|
||||
excerpt: "",
|
||||
category_id: null,
|
||||
tag_ids: [],
|
||||
writer_ids: [],
|
||||
status: "draft",
|
||||
is_featured: false,
|
||||
seo_title: "",
|
||||
seo_description: "",
|
||||
canonical_url: "",
|
||||
og_title: "",
|
||||
og_description: "",
|
||||
noindex: false,
|
||||
focus_keyword: "",
|
||||
};
|
||||
|
||||
export default function AdminBlogEditor({ postId }: Props) {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const featuredInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [form, setForm] = useState<Types.PostCreateSchema>(emptyForm);
|
||||
const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
|
||||
const [categories, setCategories] = useState<Types.CategorySchema[]>([]);
|
||||
const [tags, setTags] = useState<Types.TagSchema[]>([]);
|
||||
const [users, setUsers] = useState<NonNullable<Types.PostListSchema["writers"]>>([]);
|
||||
const [loading, setLoading] = useState(Boolean(postId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingFeatured, setUploadingFeatured] = useState(false);
|
||||
const [editorDirection, setEditorDirection] = useState<MarkdownDirectionMode>("auto");
|
||||
|
||||
const isNew = postId == null;
|
||||
const featuredImage = post?.absolute_featured_image_preview_url || post?.absolute_featured_image_url || post?.featured_image;
|
||||
const canPersistPost = form.title.trim() && form.content.trim();
|
||||
const canAssignWriters = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
||||
const reviewNote = post?.status === "changes_requested" ? post.review_note?.trim() : "";
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getCategories(), api.getTags()])
|
||||
.then(([categoryData, tagData]) => {
|
||||
setCategories(categoryData);
|
||||
setTags(tagData);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAssignWriters) return;
|
||||
api.listBlogWriters()
|
||||
.then((data) => setUsers(data))
|
||||
.catch(() => undefined);
|
||||
}, [canAssignWriters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postId) return;
|
||||
setLoading(true);
|
||||
api.getAdminBlogPost(postId)
|
||||
.then((data) => {
|
||||
setPost(data);
|
||||
setForm({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
excerpt: data.excerpt ?? "",
|
||||
category_id: data.category?.id ?? null,
|
||||
tag_ids: data.tags.map((tag) => tag.id),
|
||||
writer_ids: data.writers?.map((writer) => writer.id) ?? [data.author.id],
|
||||
status: data.status as Types.PostCreateSchema["status"],
|
||||
is_featured: data.is_featured,
|
||||
seo_title: data.seo_title ?? "",
|
||||
seo_description: data.seo_description ?? "",
|
||||
canonical_url: data.canonical_url ?? "",
|
||||
og_title: data.og_title ?? "",
|
||||
og_description: data.og_description ?? "",
|
||||
noindex: Boolean(data.noindex),
|
||||
focus_keyword: data.focus_keyword ?? "",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: "دریافت نوشته ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [postId, toast]);
|
||||
|
||||
const selectedTagIds = useMemo(() => form.tag_ids ?? [], [form.tag_ids]);
|
||||
const selectedWriterIds = useMemo(() => form.writer_ids ?? [], [form.writer_ids]);
|
||||
|
||||
const updateForm = <K extends keyof Types.PostCreateSchema>(key: K, value: Types.PostCreateSchema[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const savePost = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { ...form, status: form.status || "draft" };
|
||||
const saved = isNew ? await api.createPost(payload) : await api.updatePost(postId, payload);
|
||||
setPost(saved);
|
||||
toast({ title: "نوشته ذخیره شد", variant: "success" });
|
||||
if (isNew) {
|
||||
router.replace(`/admin/blog/${saved.id}/edit`);
|
||||
}
|
||||
return saved;
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "ذخیره ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitForReview = async () => {
|
||||
const saved = await savePost();
|
||||
if (!saved) return;
|
||||
try {
|
||||
const submitted = await api.submitBlogPost(saved.id);
|
||||
setPost(submitted);
|
||||
updateForm("status", submitted.status as Types.PostCreateSchema["status"]);
|
||||
toast({ title: "برای بررسی ارسال شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "ارسال ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSavedPost = async () => {
|
||||
if (post?.id) return post;
|
||||
if (!canPersistPost) {
|
||||
toast({
|
||||
title: "ابتدا نوشته را کامل کنید",
|
||||
description: "برای ذخیره پیشنویس و باز کردن مرکز آپلود، عنوان و متن نوشته لازم است.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return savePost();
|
||||
};
|
||||
|
||||
const onFeaturedImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = "";
|
||||
if (!file) return;
|
||||
|
||||
const targetPost = await ensureSavedPost();
|
||||
if (!targetPost) return;
|
||||
|
||||
setUploadingFeatured(true);
|
||||
try {
|
||||
const updated = await api.uploadBlogPostFeaturedImage(targetPost.id, file);
|
||||
setPost(updated);
|
||||
toast({ title: "تصویر شاخص بهروزرسانی شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "آپلود تصویر شاخص ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploadingFeatured(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFeaturedImage = async () => {
|
||||
if (!post?.id) return;
|
||||
setUploadingFeatured(true);
|
||||
try {
|
||||
const updated = await api.deleteBlogPostFeaturedImage(post.id);
|
||||
setPost(updated);
|
||||
toast({ title: "تصویر شاخص حذف شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "حذف تصویر شاخص ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploadingFeatured(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center" dir="rtl">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-3xl border bg-background/90 p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="text-right">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
|
||||
<Badge variant="outline">{form.status || "draft"}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
نوشتن مارکداون، پیشنمایش زنده و تنظیمات انتشار در یک محیط مینیمال.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-start gap-2">
|
||||
<Button variant="outline" onClick={() => router.push("/admin/blog")}>
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={savePost} disabled={saving}>
|
||||
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Save className="ml-2 h-4 w-4" />}
|
||||
ذخیره پیشنویس
|
||||
</Button>
|
||||
<Button onClick={submitForReview} disabled={saving || !canPersistPost}>
|
||||
<Send className="ml-2 h-4 w-4" />
|
||||
ارسال برای بررسی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviewNote ? (
|
||||
<div className="rounded-3xl border border-amber-300/70 bg-amber-50 p-5 text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div className="flex items-start gap-3 text-right">
|
||||
<AlertTriangle className="mt-1 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="space-y-2">
|
||||
<p className="font-bold">این نوشته نیازمند اصلاح است</p>
|
||||
<p className="text-sm leading-7">{reviewNote}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>مشخصات نوشته</CardTitle>
|
||||
<CardDescription>اطلاعات اصلی، دستهبندی، نویسندگان، تصویر شاخص و فایلهای ضمیمه.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 xl:grid-cols-[1fr_320px]">
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">عنوان</Label>
|
||||
<Input value={form.title} onChange={(event) => updateForm("title", event.target.value)} className="text-right" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">دستهبندی</Label>
|
||||
<Select
|
||||
value={form.category_id ? String(form.category_id) : "none"}
|
||||
onValueChange={(value) => updateForm("category_id", value === "none" ? null : Number(value))}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">بدون دستهبندی</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={String(category.id)}>{category.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">خلاصه</Label>
|
||||
<Textarea value={form.excerpt ?? ""} onChange={(event) => updateForm("excerpt", event.target.value)} className="min-h-20 text-right" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">وضعیت</Label>
|
||||
<Select
|
||||
value={form.status || "draft"}
|
||||
onValueChange={(value) => updateForm("status", value as Types.PostCreateSchema["status"])}
|
||||
disabled={!canAssignWriters}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">پیشنویس</SelectItem>
|
||||
<SelectItem value="submitted">در انتظار بررسی</SelectItem>
|
||||
<SelectItem value="changes_requested">نیازمند تغییر</SelectItem>
|
||||
<SelectItem value="published">منتشر شده</SelectItem>
|
||||
<SelectItem value="archived">آرشیو</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-8">
|
||||
<Label>نوشته ویژه</Label>
|
||||
<Checkbox checked={Boolean(form.is_featured)} onCheckedChange={(checked) => updateForm("is_featured", Boolean(checked))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">برچسبها</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => {
|
||||
const selected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<Button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
onClick={() => updateForm("tag_ids", selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id])}
|
||||
>
|
||||
{tag.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canAssignWriters ? (
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">نویسندگان</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{users.map((writer) => {
|
||||
const selected = selectedWriterIds.includes(writer.id);
|
||||
const fullName = [writer.first_name, writer.last_name].filter(Boolean).join(" ") || writer.username;
|
||||
return (
|
||||
<Button
|
||||
key={writer.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
onClick={() => updateForm("writer_ids", selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id])}
|
||||
>
|
||||
{fullName}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-2 text-right text-xs text-muted-foreground">
|
||||
مالک اصلی نوشته تغییر نمیکند؛ این گزینه فقط لیست نویسندگان عمومی را تنظیم میکند.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} />
|
||||
<div className="overflow-hidden rounded-2xl border bg-muted">
|
||||
{featuredImage ? (
|
||||
<img src={featuredImage} alt={post?.title || form.title || "thumbnail"} className="aspect-video w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-video items-center justify-center text-sm text-muted-foreground">
|
||||
تصویری انتخاب نشده است.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{post?.featured_image || post?.absolute_featured_image_url ? (
|
||||
<ConfirmAction
|
||||
title="حذف تصویر شاخص"
|
||||
description="آیا از حذف تصویر شاخص این نوشته مطمئن هستید؟"
|
||||
onConfirm={deleteFeaturedImage}
|
||||
disabled={uploadingFeatured}
|
||||
trigger={
|
||||
<Button variant="outline" disabled={uploadingFeatured}>
|
||||
<Trash2 className="ml-2 h-4 w-4" />
|
||||
حذف تصویر
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
|
||||
{uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}
|
||||
انتخاب تصویر شاخص
|
||||
</Button>
|
||||
</div>
|
||||
{post?.id ? (
|
||||
<Button asChild className="w-full justify-center rounded-2xl py-6" variant="outline">
|
||||
<Link to={`/admin/blog/${post.id}/assets`}>
|
||||
<FolderUp className="ml-2 h-4 w-4" />
|
||||
رفتن به مرکز آپلود
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed p-4 text-right text-sm text-muted-foreground">
|
||||
برای باز شدن مرکز آپلود، ابتدا نوشته را به عنوان پیشنویس ذخیره کنید.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>متن نوشته</CardTitle>
|
||||
<CardDescription>ویرایشگر مارکداون در کنار پیشنمایش زنده</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="hidden grid-cols-2 gap-0 overflow-hidden bg-muted/20 md:grid">
|
||||
<div className="bg-background ">
|
||||
{/* <div className="border-b px-4 py-3 text-right text-sm font-medium">متن مارکداون</div> */}
|
||||
<MarkdownEditor
|
||||
value={form.content}
|
||||
onChange={(value) => updateForm("content", value)}
|
||||
minHeight="620px"
|
||||
directionMode={editorDirection}
|
||||
onDirectionModeChange={setEditorDirection}
|
||||
onSave={savePost}
|
||||
className="rounded-none border-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-background">
|
||||
{/* <div className="border-b px-4 py-3 text-right text-sm font-medium">پیشنمایش</div> */}
|
||||
<div className="min-h-[560px] p-5">
|
||||
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify={false} size="base" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="editor" className="md:hidden" dir="rtl">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="editor">ویرایش</TabsTrigger>
|
||||
<TabsTrigger value="preview">پیشنمایش</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="editor">
|
||||
<MarkdownEditor
|
||||
value={form.content}
|
||||
onChange={(value) => updateForm("content", value)}
|
||||
minHeight="520px"
|
||||
directionMode={editorDirection}
|
||||
onDirectionModeChange={setEditorDirection}
|
||||
onSave={savePost}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview">
|
||||
<div className="min-h-[520px] rounded-2xl border bg-background p-4">
|
||||
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify={false} size="base" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>تنظیمات سئو</CardTitle>
|
||||
{/* <CardDescription>این بخش جدا از متن اصلی است تا جریان نوشتن ساده و متمرکز بماند.</CardDescription> */}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">SEO Title</Label>
|
||||
<Input value={form.seo_title ?? ""} onChange={(event) => updateForm("seo_title", event.target.value)} className="text-right" maxLength={70} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">Focus Keyword</Label>
|
||||
<Input value={form.focus_keyword ?? ""} onChange={(event) => updateForm("focus_keyword", event.target.value)} className="text-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">SEO Description</Label>
|
||||
<Textarea value={form.seo_description ?? ""} onChange={(event) => updateForm("seo_description", event.target.value)} className="min-h-20 text-right" maxLength={170} />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">Canonical URL</Label>
|
||||
<Input value={form.canonical_url ?? ""} onChange={(event) => updateForm("canonical_url", event.target.value)} dir="ltr" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">OG Title</Label>
|
||||
<Input value={form.og_title ?? ""} onChange={(event) => updateForm("og_title", event.target.value)} className="text-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">OG Description</Label>
|
||||
<Textarea value={form.og_description ?? ""} onChange={(event) => updateForm("og_description", event.target.value)} className="min-h-20 text-right" />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 rounded-2xl border p-4">
|
||||
<Label>noindex</Label>
|
||||
<Checkbox checked={Boolean(form.noindex)} onCheckedChange={(checked) => updateForm("noindex", Boolean(checked))} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowRight, Edit, ExternalLink, Loader2 } from "lucide-react";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import { Link } from "@/lib/router";
|
||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import { blogPostPath } from "@/lib/blog-routes";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalali, resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
postId: number;
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "پیشنویس",
|
||||
submitted: "در انتظار بررسی",
|
||||
changes_requested: "نیازمند اصلاح",
|
||||
published: "منتشر شده",
|
||||
archived: "آرشیو شده",
|
||||
};
|
||||
|
||||
export default function AdminBlogPreview({ postId }: Props) {
|
||||
const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(postId)) {
|
||||
setError("شناسه نوشته نامعتبر است.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
api.getAdminBlogPost(postId)
|
||||
.then((data) => {
|
||||
if (isMounted) setPost(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isMounted) {
|
||||
setError(resolveErrorMessage(err, "نوشته برای پیشنمایش یافت نشد."));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [postId]);
|
||||
|
||||
const image = useMemo(() => {
|
||||
if (!post) return undefined;
|
||||
return toAbsoluteUrl(
|
||||
post.og_image_url || post.absolute_featured_image_url || post.featured_image,
|
||||
apiBaseUrl,
|
||||
);
|
||||
}, [post]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center" dir="rtl">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8" dir="rtl">
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>پیشنمایش نوشته در دسترس نیست</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/admin/blog">
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
بازگشت به مدیریت بلاگ
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/admin/blog">
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
بازگشت
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary">
|
||||
<Link to={`/admin/blog/${post.id}/edit`}>
|
||||
<Edit className="ml-2 h-4 w-4" />
|
||||
ویرایش
|
||||
</Link>
|
||||
</Button>
|
||||
{post.status === "published" && post.slug ? (
|
||||
<Button asChild>
|
||||
<Link to={blogPostPath(post.slug)}>
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
نسخه عمومی
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge variant={post.status === "published" ? "default" : "secondary"}>
|
||||
{statusLabels[post.status] ?? post.status}
|
||||
</Badge>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
آخرین بهروزرسانی: {formatJalali(post.updated_at, false)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{post.status !== "published" ? (
|
||||
<div className="mb-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-right text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
این نسخه پیشنمایش داخلی است و تا زمان انتشار در صفحه عمومی وبلاگ دیده نمیشود.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{image ? (
|
||||
<div className="aspect-video w-full overflow-hidden rounded-t-lg bg-muted">
|
||||
<img src={image} alt={post.title} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
) : null}
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.category?.name ? <Badge variant="secondary">{post.category.name}</Badge> : null}
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag.id} variant="outline">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<CardTitle className="text-3xl leading-relaxed">{post.title}</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
نویسنده: {[post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{post.excerpt ? (
|
||||
<p className="rounded-lg border bg-muted/30 p-4 text-sm leading-7 text-muted-foreground">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
<Markdown content={post.content} justify size="base" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type TagForm = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const emptyForm: TagForm = {
|
||||
name: "",
|
||||
slug: "",
|
||||
};
|
||||
|
||||
export default function AdminBlogTags() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [search, setSearch] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Types.AdminTagSchema | null>(null);
|
||||
const [form, setForm] = useState<TagForm>(emptyForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const canDelete = Boolean(user?.is_superuser);
|
||||
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: ["admin", "blog", "tags"],
|
||||
queryFn: () => api.listAdminTags(),
|
||||
});
|
||||
|
||||
const deletedQuery = useQuery({
|
||||
queryKey: ["admin", "blog", "tags", "deleted"],
|
||||
queryFn: () => api.listDeletedTags(),
|
||||
enabled: canDelete,
|
||||
});
|
||||
|
||||
const tags = useMemo(() => tagsQuery.data ?? [], [tagsQuery.data]);
|
||||
const visibleTags = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return tags;
|
||||
return tags.filter((tag) => [tag.name, tag.slug].some((value) => value.toLowerCase().includes(needle)));
|
||||
}, [search, tags]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (tag: Types.AdminTagSchema) => {
|
||||
setEditing(tag);
|
||||
setForm({ name: tag.name, slug: tag.slug });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = (force = false) => {
|
||||
if (submitting && !force) return;
|
||||
setDialogOpen(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const saveTag = async () => {
|
||||
const payload: Types.TagWriteSchema = {
|
||||
name: form.name.trim(),
|
||||
slug: form.slug.trim() || null,
|
||||
};
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await api.updateTag(editing.id, payload);
|
||||
} else {
|
||||
await api.createTag(payload);
|
||||
}
|
||||
toast({ title: editing ? "برچسب ویرایش شد" : "برچسب ساخته شد", variant: "success" });
|
||||
await tagsQuery.refetch();
|
||||
closeDialog(true);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "ذخیره برچسب ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTag = async (tag: Types.AdminTagSchema) => {
|
||||
try {
|
||||
await api.deleteTag(tag.id);
|
||||
toast({ title: "برچسب حذف شد", variant: "success" });
|
||||
await Promise.all([tagsQuery.refetch(), deletedQuery.refetch()]);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "حذف برچسب ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const restoreTag = async (tag: Types.TagSchema) => {
|
||||
try {
|
||||
await api.restoreTag(tag.id);
|
||||
toast({ title: "برچسب بازیابی شد", variant: "success" });
|
||||
await Promise.all([tagsQuery.refetch(), deletedQuery.refetch()]);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "بازیابی برچسب ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-right">
|
||||
<h2 className="text-2xl font-bold">برچسبهای بلاگ</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">مدیریت موضوعات و برچسبهایی که روی نوشتهها استفاده میشوند.</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
برچسب جدید
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>لیست برچسبها</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو در نام یا اسلاگ..." className="text-right" />
|
||||
{tagsQuery.isLoading ? (
|
||||
<div className="flex justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : visibleTags.length ? (
|
||||
<div className="overflow-x-auto rounded-2xl border">
|
||||
<table className="w-full min-w-[620px] text-sm">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-right">عنوان</th>
|
||||
{/* <th className="px-4 py-3 text-right">اسلاگ</th> */}
|
||||
<th className="px-4 py-3 text-right">تعداد نوشته</th>
|
||||
<th className="px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleTags.map((tag) => (
|
||||
<tr key={tag.id} className="border-t">
|
||||
<td className="px-4 py-3 font-medium">{tag.name}</td>
|
||||
{/* <td className="px-4 py-3 text-muted-foreground">{tag.slug}</td> */}
|
||||
<td className="px-4 py-3">{toPersianDigits(String(tag.post_count))}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(tag)}>
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<ConfirmAction
|
||||
title="حذف برچسب"
|
||||
description={`آیا از حذف برچسب «${tag.name}» مطمئن هستید؟`}
|
||||
onConfirm={() => deleteTag(tag)}
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">برچسبی یافت نشد.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canDelete ? (
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>برچسبهای حذفشده</CardTitle>
|
||||
<CardDescription>بازیابی رکوردهای حذف شده.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{deletedQuery.data?.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{deletedQuery.data.map((tag) => (
|
||||
<div key={tag.id} className="flex items-center justify-between rounded-2xl border p-3">
|
||||
<span className="font-medium">{tag.name}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => void restoreTag(tag)}>
|
||||
<RotateCcw className="ml-1 h-3.5 w-3.5" />
|
||||
بازیابی
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">مورد حذفشدهای وجود ندارد.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent dir="rtl" className="text-right">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "ویرایش برچسب" : "برچسب جدید"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">نام</Label>
|
||||
<Input value={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">اسلاگ اختیاری</Label>
|
||||
<Input value={form.slug} onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))} dir="ltr" />
|
||||
</div>
|
||||
<div className="flex justify-start gap-2">
|
||||
<Button variant="outline" onClick={() => closeDialog()} disabled={submitting}>انصراف</Button>
|
||||
<Button onClick={saveTag} disabled={submitting || !form.name.trim()}>
|
||||
{submitting ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit3, Plus, Trash2 } from "lucide-react";
|
||||
import AdminDateTimeField from "@/components/AdminDateTimeField";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { DiscountCodeSchema, DiscountCodeWriteSchema } from "@/lib/types";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const emptyForm: DiscountCodeWriteSchema = {
|
||||
code: "",
|
||||
type: "percent",
|
||||
value: 0,
|
||||
max_discount: null,
|
||||
is_active: true,
|
||||
starts_at: null,
|
||||
ends_at: null,
|
||||
usage_limit_total: null,
|
||||
usage_limit_per_user: null,
|
||||
min_amount: null,
|
||||
applicable_event_ids: [],
|
||||
};
|
||||
|
||||
export default function AdminCoupons() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [editing, setEditing] = React.useState<DiscountCodeSchema | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [form, setForm] = React.useState<DiscountCodeWriteSchema>(emptyForm);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "coupons", debouncedSearch, page],
|
||||
queryFn: () => api.listDiscountCodes({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => (editing ? api.updateDiscountCode(editing.id, form) : api.createDiscountCode(form)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "coupons"] });
|
||||
setOpen(false);
|
||||
toast({ title: "کد تخفیف ذخیره شد", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.deleteDiscountCode(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "coupons"] });
|
||||
toast({ title: "کد تخفیف حذف شد", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: DiscountCodeSchema) => {
|
||||
setEditing(item);
|
||||
setForm({
|
||||
code: item.code,
|
||||
type: item.type,
|
||||
value: item.value,
|
||||
max_discount: item.max_discount,
|
||||
is_active: item.is_active,
|
||||
starts_at: item.starts_at,
|
||||
ends_at: item.ends_at,
|
||||
usage_limit_total: item.usage_limit_total,
|
||||
usage_limit_per_user: item.usage_limit_per_user,
|
||||
min_amount: item.min_amount,
|
||||
applicable_event_ids: item.applicable_event_ids,
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const items = query.data?.results ?? [];
|
||||
const count = query.data?.count ?? 0;
|
||||
const hasMore = page * PAGE_SIZE < count;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">کدهای تخفیف</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">مدیریت کدهای تخفیف رویدادها</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>فهرست کدها</CardTitle>
|
||||
<CardDescription>جستجو و مدیریت وضعیت کدهای تخفیف</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="جستجو بر اساس کد..."
|
||||
className="max-w-md"
|
||||
/>
|
||||
<div className="overflow-x-auto rounded-2xl border">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-right">کد</th>
|
||||
<th className="px-4 py-3 text-right">نوع</th>
|
||||
<th className="px-4 py-3 text-right">مقدار</th>
|
||||
<th className="px-4 py-3 text-right">استفاده</th>
|
||||
<th className="px-4 py-3 text-right">وضعیت</th>
|
||||
<th className="px-4 py-3 text-left"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{query.isLoading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-muted-foreground">در حال بارگذاری...</td></tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-muted-foreground">کدی یافت نشد.</td></tr>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<tr key={item.id} className="border-t hover:bg-muted/40">
|
||||
<td className="px-4 py-3 font-mono font-bold">{item.code}</td>
|
||||
<td className="px-4 py-3">{item.type === "percent" ? "درصدی" : "مبلغ ثابت"}</td>
|
||||
<td className="px-4 py-3">{formatNumberPersian(item.value)}</td>
|
||||
<td className="px-4 py-3">{formatNumberPersian(item.usage_count)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={item.is_active ? "default" : "outline"}>{item.is_active ? "فعال" : "غیرفعال"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<ConfirmAction
|
||||
title="حذف کد تخفیف"
|
||||
description={`آیا از حذف کد «${item.code}» مطمئن هستید؟ این کد دیگر در لیستهای عادی نمایش داده نمیشود.`}
|
||||
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
trigger={
|
||||
<Button size="icon" variant="outline" className="text-destructive hover:text-destructive" disabled={deleteMutation.isPending} aria-label="حذف">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>صفحه {formatNumberPersian(page)} از {formatNumberPersian(Math.max(1, Math.ceil(count / PAGE_SIZE)))}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>قبلی</Button>
|
||||
<Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((current) => current + 1)}>بعدی</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" dir="rtl">
|
||||
<DialogHeader className="text-right">
|
||||
<DialogTitle>{editing ? "ویرایش کد تخفیف" : "افزودن کد تخفیف"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>کد</Label>
|
||||
<Input dir="ltr" value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>نوع</Label>
|
||||
<Select value={form.type} onValueChange={(value) => setForm((current) => ({ ...current, type: value as "percent" | "fixed" }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="percent">درصدی</SelectItem>
|
||||
<SelectItem value="fixed">مبلغ ثابت</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>مقدار</Label>
|
||||
<Input type="number" value={form.value} onChange={(event) => setForm((current) => ({ ...current, value: Number(event.target.value) }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>حداکثر تخفیف</Label>
|
||||
<Input type="number" value={form.max_discount ?? ""} onChange={(event) => setForm((current) => ({ ...current, max_discount: event.target.value ? Number(event.target.value) : null }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>حداقل مبلغ</Label>
|
||||
<Input type="number" value={form.min_amount ?? ""} onChange={(event) => setForm((current) => ({ ...current, min_amount: event.target.value ? Number(event.target.value) : null }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>محدودیت کل</Label>
|
||||
<Input type="number" value={form.usage_limit_total ?? ""} onChange={(event) => setForm((current) => ({ ...current, usage_limit_total: event.target.value ? Number(event.target.value) : null }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>محدودیت هر کاربر</Label>
|
||||
<Input type="number" value={form.usage_limit_per_user ?? ""} onChange={(event) => setForm((current) => ({ ...current, usage_limit_per_user: event.target.value ? Number(event.target.value) : null }))} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||
<Label>فعال</Label>
|
||||
<Switch checked={form.is_active ?? true} onCheckedChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))} />
|
||||
</div>
|
||||
<AdminDateTimeField label="شروع اعتبار" value={form.starts_at} onChange={(value) => setForm((current) => ({ ...current, starts_at: value }))} />
|
||||
<AdminDateTimeField label="پایان اعتبار" value={form.ends_at} onChange={(value) => setForm((current) => ({ ...current, ends_at: value }))} />
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-start">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>انصراف</Button>
|
||||
<Button disabled={saveMutation.isPending || !form.code.trim() || form.value <= 0} onClick={() => saveMutation.mutate()}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,277 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Clock3, XCircle } from "lucide-react";
|
||||
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Link, Navigate, useParams } from "@/lib/router";
|
||||
import type { RegistrationAdminSchema } from "@/lib/types";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatJalali, formatToman, getEventCardImageUrl, resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import * as React from 'react';
|
||||
import { useParams, Link, Navigate } from '@/lib/router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { formatJalali, formatToman, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const REGISTRATIONS_PAGE_SIZE = 10;
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "all", label: "همه" },
|
||||
{ value: "confirmed", label: "تایید شده" },
|
||||
{ value: "pending", label: "در انتظار" },
|
||||
{ value: "cancelled", label: "لغو شده" },
|
||||
{ value: "attended", label: "حضور یافته" },
|
||||
const registrationStatusOptions = [
|
||||
{ value: 'confirmed', label: 'تایید شده' },
|
||||
{ value: 'pending', label: 'در انتظار' },
|
||||
{ value: 'cancelled', label: 'لغو شده' },
|
||||
{ value: 'attended', label: 'حضور یافته' },
|
||||
] as const;
|
||||
|
||||
function initials(registration: RegistrationAdminSchema) {
|
||||
const text = [registration.user.first_name, registration.user.last_name].filter(Boolean).join(" ") || registration.user.username;
|
||||
return text.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function statusIcon(status: RegistrationAdminSchema["status"]) {
|
||||
if (status === "confirmed" || status === "attended") return <CheckCircle2 className="h-4 w-4 text-emerald-500" />;
|
||||
if (status === "pending") return <Clock3 className="h-4 w-4 text-amber-500" />;
|
||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
}
|
||||
|
||||
function RegistrationDialog({
|
||||
registration,
|
||||
onOpenChange,
|
||||
}: {
|
||||
registration: RegistrationAdminSchema | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={Boolean(registration)} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto" dir="rtl">
|
||||
<DialogHeader className="text-right">
|
||||
<DialogTitle>جزئیات ثبتنام</DialogTitle>
|
||||
</DialogHeader>
|
||||
{registration ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border p-4">
|
||||
<Avatar className="h-14 w-14">
|
||||
<AvatarImage src={registration.user.profile_picture_thumbnail_url || registration.user.profile_picture || undefined} />
|
||||
<AvatarFallback>{initials(registration)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 text-right">
|
||||
<div className="font-bold">{registration.user.first_name} {registration.user.last_name}</div>
|
||||
<div className="text-sm text-muted-foreground">{registration.user.mobile || registration.user.email}</div>
|
||||
</div>
|
||||
<Badge className="mr-auto" variant={registration.status === "cancelled" ? "destructive" : "secondary"}>
|
||||
{registration.status_label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Info label="موبایل" value={registration.user.mobile} />
|
||||
<Info label="ایمیل" value={registration.user.email} />
|
||||
<Info label="دانشگاه" value={registration.user.university} />
|
||||
<Info label="رشته" value={registration.user.major} />
|
||||
<Info label="شماره دانشجویی" value={registration.user.student_id} />
|
||||
<Info label="کد بلیت" value={registration.ticket_id} />
|
||||
<Info label="تاریخ ثبتنام" value={formatJalali(registration.registered_at)} />
|
||||
<Info label="مبلغ نهایی" value={formatToman(registration.final_price ?? 0)} />
|
||||
<Info label="تخفیف" value={formatToman(registration.discount_amount ?? 0)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold">پرداختها</div>
|
||||
{registration.payments.length ? registration.payments.map((payment) => (
|
||||
<div key={payment.id} className="rounded-2xl border bg-muted/20 p-3 text-sm">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Info label="وضعیت" value={payment.status_label} />
|
||||
<Info label="مبلغ" value={formatToman(payment.amount)} />
|
||||
<Info label="کد رهگیری" value={payment.ref_id} />
|
||||
<Info label="Authority" value={payment.authority} />
|
||||
<Info label="کارت" value={payment.card_pan} />
|
||||
<Info label="کد تخفیف" value={payment.discount_code} />
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="text-sm text-muted-foreground">پرداختی ثبت نشده است.</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value }: { label: string; value?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl bg-background px-3 py-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="text-left font-medium">{value || "—"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const REGISTRATIONS_PAGE_SIZE = 10;
|
||||
|
||||
export default function AdminEventDetail() {
|
||||
const { id } = useParams();
|
||||
const { toast } = useToast();
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const eventId = Number(id);
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
const [university, setUniversity] = React.useState<string | null>(null);
|
||||
const [major, setMajor] = React.useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = React.useState<typeof registrationStatusOptions[number]['value'] | 'all'>('all');
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [regPage, setRegPage] = React.useState(1);
|
||||
const [selectedRegistration, setSelectedRegistration] = React.useState<RegistrationAdminSchema | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const eventId = Number(id);
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["admin", "event-detail", eventId],
|
||||
queryKey: ['admin', 'event-detail', eventId],
|
||||
queryFn: () => api.getEventAdminDetail(eventId),
|
||||
enabled: Number.isFinite(eventId),
|
||||
});
|
||||
|
||||
const registrationsQuery = useQuery({
|
||||
queryKey: ["admin", "event", eventId, "registrations", statusFilter, debouncedSearch, university, major, regPage],
|
||||
queryKey: ['admin', 'event', eventId, 'registrations', statusFilter, search, regPage],
|
||||
enabled: Number.isFinite(eventId),
|
||||
queryFn: () =>
|
||||
api.listEventRegistrationsAdmin(eventId, {
|
||||
statuses: statusFilter === "all" ? undefined : [statusFilter],
|
||||
search: debouncedSearch || undefined,
|
||||
university: university || undefined,
|
||||
major: major || undefined,
|
||||
statuses:
|
||||
statusFilter === 'all'
|
||||
? registrationStatusOptions.map((s) => s.value)
|
||||
: [statusFilter],
|
||||
search: search || undefined,
|
||||
limit: REGISTRATIONS_PAGE_SIZE,
|
||||
offset: (regPage - 1) * REGISTRATIONS_PAGE_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const error = detailQuery.error || registrationsQuery.error;
|
||||
if (error) toast({ title: "خطا در دریافت اطلاعات رویداد", description: resolveErrorMessage(error), variant: "destructive" });
|
||||
}, [detailQuery.error, registrationsQuery.error, toast]);
|
||||
if (detailQuery.error) {
|
||||
toast({ title: 'خطا در دریافت جزئیات رویداد', description: resolveErrorMessage(detailQuery.error), variant: 'destructive' });
|
||||
}
|
||||
}, [detailQuery.error, toast]);
|
||||
|
||||
const loadMajors = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||
const data = await api.getMajorsPaged(params);
|
||||
return { count: data.count, results: data.results.map((item) => ({ value: item.code, label: item.label })) };
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (registrationsQuery.error) {
|
||||
toast({ title: 'خطا در ثبتنامها', description: resolveErrorMessage(registrationsQuery.error), variant: 'destructive' });
|
||||
}
|
||||
}, [registrationsQuery.error, toast]);
|
||||
|
||||
const loadUniversities = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||
const data = await api.getUniversitiesPaged(params);
|
||||
return { count: data.count, results: data.results.map((item) => ({ value: item.code, label: item.label })) };
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="flex min-h-screen items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری...</div>;
|
||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) return <Navigate to="/" replace />;
|
||||
if (!Number.isFinite(eventId)) return <div className="flex min-h-screen items-center justify-center" dir="rtl">شناسه رویداد معتبر نیست.</div>;
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری...</div>;
|
||||
}
|
||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
if (!Number.isFinite(eventId)) {
|
||||
return <div className="min-h-screen flex items-center justify-center" dir="rtl">شناسه رویداد معتبر نیست.</div>;
|
||||
}
|
||||
|
||||
const event = detailQuery.data;
|
||||
const paged = registrationsQuery.data;
|
||||
const registrationPageCount = paged ? Math.max(1, Math.ceil(paged.count / REGISTRATIONS_PAGE_SIZE)) : 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black">{event?.title ?? "جزئیات رویداد"}</h1>
|
||||
{event ? <p className="mt-1 text-sm text-muted-foreground">شروع: {formatJalali(event.start_time)} · ثبتنامها: {toPersianDigits(event.registration_count)}</p> : null}
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{event?.title ?? 'جزئیات رویداد'}</h1>
|
||||
{event && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<Badge variant="secondary">{event.status_label ?? event.status}</Badge>
|
||||
{event.start_time ? <span>شروع: {formatJalali(event.start_time)}</span> : null}
|
||||
{event.event_type ? <span>نوع: {event.event_type_label ?? event.event_type}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link to={`/admin/events/${eventId}/edit`}>ویرایش پیشرفته</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/admin/events">بازگشت</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild><Link to={`/admin/events/${eventId}/edit`}>ویرایش</Link></Button>
|
||||
<Button variant="outline" asChild><Link to="/admin/events">بازگشت</Link></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<main className="space-y-6">
|
||||
{event ? (
|
||||
<>
|
||||
<ProgressiveImage
|
||||
src={getEventCardImageUrl(event)}
|
||||
alt={event.title}
|
||||
wrapperClassName="aspect-video overflow-hidden rounded-3xl bg-muted shadow-sm"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="grid gap-3 p-5 text-sm md:grid-cols-2">
|
||||
<Info label="نوع" value={event.event_type} />
|
||||
<Info label="وضعیت" value={event.status} />
|
||||
<Info label="ظرفیت" value={event.capacity ?? "نامحدود"} />
|
||||
<Info label="قیمت" value={Number(event.price || 0) === 0 ? "رایگان" : formatToman(event.price)} />
|
||||
<Info label="شروع ثبتنام" value={event.registration_start_date ? formatJalali(event.registration_start_date) : "—"} />
|
||||
<Info label="پایان ثبتنام" value={event.registration_end_date ? formatJalali(event.registration_end_date) : "—"} />
|
||||
<Info label="آدرس" value={event.address} />
|
||||
<Info label="لینک آنلاین" value={event.online_link} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Markdown content={event.description || "توضیحی ثبت نشده است."} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری جزئیات...</p>
|
||||
)}
|
||||
</main>
|
||||
{event && (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>وضعیت</CardTitle>
|
||||
<CardDescription>اطلاعات پایه رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<div>ظرفیت: {event.capacity ?? 'نامحدود'}</div>
|
||||
<div>ثبتنامها: {toPersianDigits(event.registration_count ?? 0)}</div>
|
||||
<div>قیمت: {formatToman(event.price)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>توضیحات</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground leading-6">
|
||||
{event.description || 'توضیحی ثبت نشده است.'}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<aside className="space-y-4 lg:sticky lg:top-24 lg:self-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>اطلاعات ثبتنام</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input placeholder="جستجو نام، موبایل یا ایمیل..." value={search} onChange={(event) => { setSearch(event.target.value); setRegPage(1); }} />
|
||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); setRegPage(1); }}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ثبتنامها و پرداختها</CardTitle>
|
||||
<CardDescription>لیست ثبتنامهای مرتبط با این رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value as typeof statusFilter); setRegPage(1); }}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="وضعیت">وضعیت: {statusFilter === 'all' ? 'همه' : statusFilter}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
{registrationStatusOptions.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AsyncSearchableCombobox value={university} onChange={(value) => { setUniversity(value); setRegPage(1); }} loadOptions={loadUniversities} placeholder="دانشگاه" />
|
||||
<AsyncSearchableCombobox value={major} onChange={(value) => { setMajor(value); setRegPage(1); }} loadOptions={loadMajors} placeholder="رشته" />
|
||||
</div>
|
||||
<Input
|
||||
className="md:w-64"
|
||||
placeholder="جستجو نام/ایمیل/نامکاربری"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setRegPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{registrationsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری...</p>
|
||||
) : !paged || paged.results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">ثبتنامی یافت نشد.</p>
|
||||
) : paged.results.map((registration) => (
|
||||
<button
|
||||
key={registration.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-2xl border bg-background p-3 text-right transition hover:bg-muted/40"
|
||||
onClick={() => setSelectedRegistration(registration)}
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={registration.user.profile_picture_thumbnail_url || registration.user.profile_picture || undefined} />
|
||||
<AvatarFallback>{initials(registration)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{registration.user.first_name} {registration.user.last_name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{registration.user.mobile || registration.user.email}</span>
|
||||
</span>
|
||||
{statusIcon(registration.status)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>صفحه {toPersianDigits(regPage)} از {toPersianDigits(registrationPageCount)}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={regPage <= 1} onClick={() => setRegPage((page) => Math.max(1, page - 1))}>قبلی</Button>
|
||||
<Button size="sm" variant="outline" disabled={regPage >= registrationPageCount} onClick={() => setRegPage((page) => page + 1)}>بعدی</Button>
|
||||
{registrationsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری ثبتنامها...</p>
|
||||
) : !paged || paged.results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">ثبتنامی یافت نشد.</p>
|
||||
) : (
|
||||
<ScrollArea className="rounded-md border max-h-[70vh]">
|
||||
<div className="divide-y">
|
||||
{paged.results.map((registration) => (
|
||||
<div key={registration.id} className="p-4">
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">{registration.user.first_name} {registration.user.last_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{registration.user.email}</div>
|
||||
</div>
|
||||
<Badge variant={registration.status === 'confirmed' ? 'default' : 'outline'}>
|
||||
{registration.status_label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>نامکاربری: {registration.user.username}</div>
|
||||
<div>کد بلیت: {registration.ticket_id}</div>
|
||||
<div>تاریخ ثبتنام: {formatJalali(registration.registered_at)}</div>
|
||||
<div>مبلغ پرداختی: {formatToman(registration.final_price ?? 0)}</div>
|
||||
<div>تخفیف: {formatToman(registration.discount_amount ?? 0)}</div>
|
||||
</div>
|
||||
{registration.payments.length > 0 && (
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="font-medium">پرداختها</div>
|
||||
{registration.payments.map((payment) => (
|
||||
<div key={payment.id} className="flex flex-wrap items-center justify-between gap-2 rounded border px-2 py-1">
|
||||
<span className="text-muted-foreground">{payment.status_label}</span>
|
||||
<span>{formatToman(payment.amount)}</span>
|
||||
<span className="text-muted-foreground text-[11px]">Ref: {payment.ref_id ?? '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<RegistrationDialog registration={selectedRegistration} onOpenChange={(open) => { if (!open) setSelectedRegistration(null); }} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>صفحه {toPersianDigits(regPage)} از {toPersianDigits(registrationPageCount)}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={regPage <= 1} onClick={() => setRegPage((p) => Math.max(1, p - 1))}>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={regPage >= registrationPageCount} onClick={() => setRegPage((p) => p + 1)}>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import AdminEventForm from "@/views/AdminEventForm";
|
||||
import * as React from 'react';
|
||||
import { useNavigate, useParams, Navigate } from '@/lib/router';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { api } from '@/lib/api';
|
||||
import type { EventAdminDetailSchema, EventUpdateSchema } from '@/lib/types';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { resolveErrorMessage } from '@/lib/utils';
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'draft', label: 'پیشنویس' },
|
||||
{ value: 'published', label: 'منتشر شده' },
|
||||
{ value: 'cancelled', label: 'لغو شده' },
|
||||
{ value: 'completed', label: 'برگزار شده' },
|
||||
];
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'online', label: 'آنلاین' },
|
||||
{ value: 'on_site', label: 'حضوری' },
|
||||
{ value: 'hybrid', label: 'ترکیبی' },
|
||||
];
|
||||
|
||||
const toInputDateTime = (iso?: string | null) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear().toString().padStart(4, '0')}-${(d.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}T${d
|
||||
.getHours()
|
||||
.toString()
|
||||
.padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export default function AdminEventEdit() {
|
||||
return <AdminEventForm mode="edit" />;
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const eventId = Number(id);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['admin', 'edit-event', eventId],
|
||||
queryFn: () => api.getEventAdminDetail(eventId),
|
||||
enabled: Boolean(eventId) && isAuthenticated,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = React.useState({
|
||||
title: '',
|
||||
status: 'draft' as NonNullable<EventUpdateSchema['status']>,
|
||||
event_type: 'online' as NonNullable<EventUpdateSchema['event_type']>,
|
||||
price: '',
|
||||
capacity: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
registration_start_date: '',
|
||||
registration_end_date: '',
|
||||
location: '',
|
||||
address: '',
|
||||
online_link: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (detailQuery.data) {
|
||||
const d: EventAdminDetailSchema = detailQuery.data;
|
||||
setFormData({
|
||||
title: d.title || '',
|
||||
status: d.status || 'draft',
|
||||
event_type: d.event_type || 'online',
|
||||
price: d.price ? Math.floor(Number(d.price) / 10).toString() : '',
|
||||
capacity: d.capacity != null ? String(d.capacity) : '',
|
||||
start_time: toInputDateTime(d.start_time),
|
||||
end_time: toInputDateTime(d.end_time),
|
||||
registration_start_date: toInputDateTime(d.registration_start_date),
|
||||
registration_end_date: toInputDateTime(d.registration_end_date),
|
||||
location: d.location || '',
|
||||
address: d.address || '',
|
||||
online_link: d.online_link || '',
|
||||
description: d.description || '',
|
||||
});
|
||||
}
|
||||
}, [detailQuery.data]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: EventUpdateSchema) => api.updateEvent(eventId, payload),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'رویداد بهروزرسانی شد', variant: 'success' });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'edit-event', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'events'] });
|
||||
navigate(`/admin/events/${eventId}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'خطا در ذخیرهسازی رویداد',
|
||||
description: resolveErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (detailQuery.error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'خطا در دریافت رویداد',
|
||||
description: resolveErrorMessage(detailQuery.error),
|
||||
});
|
||||
}
|
||||
}, [detailQuery.error, toast]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<p className="text-muted-foreground">در حال بررسی دسترسی...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ویرایش رویداد</CardTitle>
|
||||
<CardDescription>فرم کامل برای ویرایش جزئیات رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{detailQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری جزئیات...</p>
|
||||
) : detailQuery.data ? (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate({
|
||||
title: formData.title,
|
||||
status: formData.status,
|
||||
event_type: formData.event_type,
|
||||
price: formData.price ? Number(formData.price) * 10 : 0,
|
||||
capacity: formData.capacity ? Number(formData.capacity) : null,
|
||||
start_time: formData.start_time || undefined,
|
||||
end_time: formData.end_time || null,
|
||||
registration_start_date: formData.registration_start_date || null,
|
||||
registration_end_date: formData.registration_end_date || null,
|
||||
location: formData.location || null,
|
||||
address: formData.address || null,
|
||||
online_link: formData.online_link || null,
|
||||
description: formData.description || '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="عنوان رویداد"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
status: value as NonNullable<EventUpdateSchema['status']>,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="وضعیت" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={formData.event_type}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
event_type: value as NonNullable<EventUpdateSchema['event_type']>,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="نوع رویداد" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{typeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="قیمت (تومان)"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, price: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="ظرفیت"
|
||||
value={formData.capacity}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, capacity: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="تاریخ شروع"
|
||||
value={formData.start_time}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, start_time: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="تاریخ پایان"
|
||||
value={formData.end_time}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, end_time: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="شروع ثبتنام"
|
||||
value={formData.registration_start_date}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, registration_start_date: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="پایان ثبتنام"
|
||||
value={formData.registration_end_date}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, registration_end_date: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="محل برگزاری"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, location: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="آدرس دقیق"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, address: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="لینک آنلاین"
|
||||
value={formData.online_link}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, online_link: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="توضیحات رویداد"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
rows={8}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
|
||||
بازگشت
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">امکان دریافت رویداد وجود ندارد.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ImagePlus, Trash2, Upload } from "lucide-react";
|
||||
import AdminDateTimeField from "@/components/AdminDateTimeField";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Link, Navigate, useNavigate, useParams } from "@/lib/router";
|
||||
import type { EventCreateSchema, EventDetailSchema, EventGalleryItem } from "@/lib/types";
|
||||
import { api } from "@/lib/api";
|
||||
import { getEventCardImageUrl, resolveErrorMessage } from "@/lib/utils";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type Mode = "create" | "edit";
|
||||
|
||||
const emptyForm = {
|
||||
title: "",
|
||||
slug: "",
|
||||
status: "draft" as EventCreateSchema["status"],
|
||||
event_type: "on_site" as EventCreateSchema["event_type"],
|
||||
price: "",
|
||||
capacity: "",
|
||||
start_time: null as string | null,
|
||||
end_time: null as string | null,
|
||||
registration_start_date: null as string | null,
|
||||
registration_end_date: null as string | null,
|
||||
location: "",
|
||||
address: "",
|
||||
online_link: "",
|
||||
description: "",
|
||||
registration_success_markdown: "",
|
||||
};
|
||||
|
||||
function toForm(event: EventDetailSchema) {
|
||||
return {
|
||||
title: event.title || "",
|
||||
slug: event.slug || "",
|
||||
status: event.status || "draft",
|
||||
event_type: event.event_type || "on_site",
|
||||
price: event.price ? String(Math.floor(Number(event.price) / 10)) : "",
|
||||
capacity: event.capacity != null ? String(event.capacity) : "",
|
||||
start_time: event.start_time || null,
|
||||
end_time: event.end_time || null,
|
||||
registration_start_date: event.registration_start_date || null,
|
||||
registration_end_date: event.registration_end_date || null,
|
||||
location: event.location || "",
|
||||
address: event.address || "",
|
||||
online_link: event.online_link || "",
|
||||
description: event.description || "",
|
||||
registration_success_markdown: event.registration_success_markdown || "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminEventForm({ mode }: { mode: Mode }) {
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const eventId = Number(id);
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = React.useState(emptyForm);
|
||||
const [previewMode, setPreviewMode] = React.useState<"editor" | "preview">("editor");
|
||||
const [galleryPreview, setGalleryPreview] = React.useState<EventGalleryItem | null>(null);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["admin", "edit-event", eventId],
|
||||
queryFn: () => api.getEventAdminDetail(eventId),
|
||||
enabled: mode === "edit" && Number.isFinite(eventId) && isAuthenticated,
|
||||
});
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: ["admin", "event", eventId, "gallery"],
|
||||
queryFn: () => api.listEventGallery(eventId),
|
||||
enabled: mode === "edit" && Number.isFinite(eventId) && isAuthenticated,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (detailQuery.data) setForm(toForm(detailQuery.data));
|
||||
}, [detailQuery.data]);
|
||||
|
||||
const makePayload = (): EventCreateSchema => ({
|
||||
title: form.title,
|
||||
slug: form.slug || null,
|
||||
status: form.status,
|
||||
event_type: form.event_type,
|
||||
price: form.price ? Number(form.price) * 10 : 0,
|
||||
capacity: form.capacity ? Number(form.capacity) : null,
|
||||
start_time: form.start_time || new Date().toISOString(),
|
||||
end_time: form.end_time || form.start_time || new Date().toISOString(),
|
||||
registration_start_date: form.registration_start_date,
|
||||
registration_end_date: form.registration_end_date,
|
||||
location: form.location || null,
|
||||
address: form.address || null,
|
||||
online_link: form.online_link || null,
|
||||
description: form.description || "",
|
||||
registration_success_markdown: form.registration_success_markdown || null,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = makePayload();
|
||||
return mode === "edit" ? api.updateEvent(eventId, payload) : api.createEvent(payload);
|
||||
},
|
||||
onSuccess: (event) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "events"] });
|
||||
toast({ title: "رویداد ذخیره شد", variant: "success" });
|
||||
navigate(`/admin/events/${event.id}/edit`);
|
||||
},
|
||||
onError: (error) => toast({ title: "خطا در ذخیره رویداد", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const posterMutation = useMutation({
|
||||
mutationFn: (file: File) => api.uploadEventFeaturedImage(eventId, file),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "edit-event", eventId] });
|
||||
toast({ title: "پوستر ذخیره شد", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: "خطا در آپلود پوستر", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const galleryUploadMutation = useMutation({
|
||||
mutationFn: (file: File) => api.uploadEventGalleryImage(eventId, file, { title: file.name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "event", eventId, "gallery"] });
|
||||
toast({ title: "تصویر گالری افزوده شد", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: "خطا در آپلود گالری", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const galleryDeleteMutation = useMutation({
|
||||
mutationFn: (imageId: number) => api.deleteEventGalleryImage(eventId, imageId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "event", eventId, "gallery"] });
|
||||
toast({ title: "تصویر حذف شد", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: "خطا در حذف تصویر", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||
});
|
||||
|
||||
if (loading) return <div className="py-10 text-center text-muted-foreground">در حال بررسی دسترسی...</div>;
|
||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) return <Navigate to="/" />;
|
||||
if (mode === "edit" && !Number.isFinite(eventId)) return <div className="py-10 text-center">شناسه رویداد معتبر نیست.</div>;
|
||||
|
||||
const event = detailQuery.data;
|
||||
const gallery = galleryQuery.data ?? event?.gallery_images ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black">{mode === "edit" ? "ویرایش رویداد" : "افزودن رویداد"}</h1>
|
||||
<p className="text-sm text-muted-foreground">فرم کامل رویداد با توضیحات Markdown، زمانبندی، پوستر و گالری</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" asChild><Link to="/admin/events">بازگشت</Link></Button>
|
||||
<Button disabled={saveMutation.isPending || !form.title.trim()} onClick={() => saveMutation.mutate()}>ذخیره</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailQuery.isLoading ? <p className="text-sm text-muted-foreground">در حال بارگذاری...</p> : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>اطلاعات اصلی</CardTitle>
|
||||
<CardDescription>عنوان، وضعیت، نوع رویداد، ظرفیت و هزینه</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>عنوان</Label>
|
||||
<Input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>اسلاگ</Label>
|
||||
<Input dir="ltr" value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>وضعیت</Label>
|
||||
<Select value={form.status} onValueChange={(value) => setForm((current) => ({ ...current, status: value as typeof form.status }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">پیشنویس</SelectItem>
|
||||
<SelectItem value="published">منتشر شده</SelectItem>
|
||||
<SelectItem value="cancelled">لغو شده</SelectItem>
|
||||
<SelectItem value="completed">برگزار شده</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>نوع</Label>
|
||||
<Select value={form.event_type} onValueChange={(value) => setForm((current) => ({ ...current, event_type: value as typeof form.event_type }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="on_site">حضوری</SelectItem>
|
||||
<SelectItem value="online">آنلاین</SelectItem>
|
||||
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>قیمت (تومان)</Label>
|
||||
<Input type="number" value={form.price} onChange={(event) => setForm((current) => ({ ...current, price: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>ظرفیت</Label>
|
||||
<Input type="number" value={form.capacity} onChange={(event) => setForm((current) => ({ ...current, capacity: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>محل / مختصات</Label>
|
||||
<Input value={form.location} onChange={(event) => setForm((current) => ({ ...current, location: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>آدرس</Label>
|
||||
<Input value={form.address} onChange={(event) => setForm((current) => ({ ...current, address: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>لینک آنلاین</Label>
|
||||
<Input dir="ltr" value={form.online_link} onChange={(event) => setForm((current) => ({ ...current, online_link: event.target.value }))} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>زمانبندی</CardTitle>
|
||||
<CardDescription>تاریخ شمسی و زمان جداگانه نمایش داده میشود.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<AdminDateTimeField label="شروع رویداد" value={form.start_time} required onChange={(value) => setForm((current) => ({ ...current, start_time: value }))} />
|
||||
<AdminDateTimeField label="پایان رویداد" value={form.end_time} required onChange={(value) => setForm((current) => ({ ...current, end_time: value }))} />
|
||||
<AdminDateTimeField label="شروع ثبتنام" value={form.registration_start_date} onChange={(value) => setForm((current) => ({ ...current, registration_start_date: value }))} />
|
||||
<AdminDateTimeField label="پایان ثبتنام" value={form.registration_end_date} onChange={(value) => setForm((current) => ({ ...current, registration_end_date: value }))} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>پوستر</CardTitle>
|
||||
<CardDescription>تصویر شاخص رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{event ? (
|
||||
<ProgressiveImage
|
||||
src={getEventCardImageUrl(event)}
|
||||
alt={event.title}
|
||||
wrapperClassName="aspect-video max-w-xl overflow-hidden rounded-2xl bg-muted"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-video max-w-xl items-center justify-center rounded-2xl border bg-muted/30 text-muted-foreground">
|
||||
<ImagePlus className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
{mode === "edit" ? (
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted">
|
||||
<Upload className="h-4 w-4" />
|
||||
آپلود پوستر
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) posterMutation.mutate(file);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">پس از ذخیره اولیه، امکان آپلود پوستر فعال میشود.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>متن رویداد</CardTitle>
|
||||
<CardDescription>ویرایشگر Markdown و پیشنمایش</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2 md:hidden">
|
||||
<Button variant={previewMode === "editor" ? "default" : "outline"} size="sm" onClick={() => setPreviewMode("editor")}>ویرایش</Button>
|
||||
<Button variant={previewMode === "preview" ? "default" : "outline"} size="sm" onClick={() => setPreviewMode("preview")}>پیشنمایش</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className={previewMode === "preview" ? "hidden lg:block" : ""}>
|
||||
<MarkdownEditor value={form.description} onChange={(value) => setForm((current) => ({ ...current, description: value }))} minHeight="520px" onSave={() => saveMutation.mutate()} />
|
||||
</div>
|
||||
<div className={previewMode === "editor" ? "hidden lg:block" : ""}>
|
||||
<div className="min-h-[520px] rounded-2xl border bg-background p-5">
|
||||
<Markdown content={form.description || "هنوز متنی وارد نشده است."} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>پیام موفقیت ثبتنام</CardTitle>
|
||||
<CardDescription>متنی که بعد از ثبتنام موفق به کاربر نمایش داده میشود.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={form.registration_success_markdown}
|
||||
onChange={(event) => setForm((current) => ({ ...current, registration_success_markdown: event.target.value }))}
|
||||
rows={6}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>گالری رویداد</CardTitle>
|
||||
<CardDescription>تصاویر مرتبط با رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{mode === "edit" ? (
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted">
|
||||
<Upload className="h-4 w-4" />
|
||||
آپلود تصویر
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
files.forEach((file) => galleryUploadMutation.mutate(file));
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">پس از ذخیره اولیه، امکان آپلود گالری فعال میشود.</p>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{gallery.map((item) => (
|
||||
<div key={item.id} className="overflow-hidden rounded-2xl border bg-card">
|
||||
<button type="button" className="block w-full" onClick={() => setGalleryPreview(item)}>
|
||||
<ProgressiveImage
|
||||
src={item.absolute_image_preview_url || item.absolute_image_url}
|
||||
alt={item.title}
|
||||
wrapperClassName="aspect-video w-full bg-muted"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-2 p-3 text-sm">
|
||||
<span className="truncate">{item.title}</span>
|
||||
<ConfirmAction
|
||||
title="حذف تصویر گالری"
|
||||
description={`آیا از حذف «${item.title}» از گالری رویداد مطمئن هستید؟`}
|
||||
onConfirm={() => galleryDeleteMutation.mutate(item.id)}
|
||||
disabled={galleryDeleteMutation.isPending}
|
||||
trigger={
|
||||
<Button size="icon" variant="ghost" className="text-destructive" disabled={galleryDeleteMutation.isPending}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{galleryPreview ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setGalleryPreview(null)}>
|
||||
<img
|
||||
src={galleryPreview.absolute_image_preview_url || galleryPreview.absolute_image_url || ""}
|
||||
alt={galleryPreview.title}
|
||||
className="max-h-[90vh] max-w-full rounded-2xl object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit3, Eye, Plus, Trash2 } from "lucide-react";
|
||||
import { Link, useNavigate } from "@/lib/router";
|
||||
import type { EventListItemSchema } from "@/lib/types";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { formatJalali, formatToman, getEventCardImageUrl, resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||
import * as React from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@/lib/router';
|
||||
import type { EventListItemSchema } from '@/lib/types';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { formatJalali, formatToman, getThumbUrl, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
||||
|
||||
const EVENTS_PAGE_SIZE = 30;
|
||||
|
||||
const eventStatusOptions = [
|
||||
{ value: "all", label: "همه وضعیتها" },
|
||||
{ value: "draft", label: "پیشنویس" },
|
||||
{ value: "published", label: "منتشر شده" },
|
||||
{ value: "cancelled", label: "لغو شده" },
|
||||
{ value: "completed", label: "برگزار شده" },
|
||||
] as const;
|
||||
{ value: 'all', label: 'همه وضعیتها' },
|
||||
{ value: 'draft', label: 'پیشنویس' },
|
||||
{ value: 'published', label: 'منتشر شده' },
|
||||
{ value: 'cancelled', label: 'لغو شده' },
|
||||
{ value: 'completed', label: 'برگزار شده' },
|
||||
];
|
||||
|
||||
const statusConfig: Record<
|
||||
EventListItemSchema["status"],
|
||||
{ label: string; variant: "outline" | "default" | "destructive" | "secondary" }
|
||||
EventListItemSchema['status'],
|
||||
{ label: string; variant: 'outline' | 'default' | 'destructive' | 'secondary' }
|
||||
> = {
|
||||
draft: { label: "پیشنویس", variant: "outline" },
|
||||
published: { label: "منتشر شده", variant: "default" },
|
||||
cancelled: { label: "لغو شده", variant: "destructive" },
|
||||
completed: { label: "برگزار شده", variant: "secondary" },
|
||||
draft: { label: 'پیشنویس', variant: 'outline' },
|
||||
published: { label: 'منتشر شده', variant: 'default' },
|
||||
cancelled: { label: 'لغو شده', variant: 'destructive' },
|
||||
completed: { label: 'برگزار شده', variant: 'secondary' },
|
||||
};
|
||||
|
||||
const eventSortOptions = [
|
||||
{ value: "newest", label: "جدیدترین شروع" },
|
||||
{ value: "oldest", label: "قدیمیترین شروع" },
|
||||
{ value: "priceAsc", label: "قیمت صعودی" },
|
||||
{ value: "priceDesc", label: "قیمت نزولی" },
|
||||
] as const;
|
||||
{ value: 'newest', label: 'جدیدترین شروع' },
|
||||
{ value: 'oldest', label: 'قدیمیترین شروع' },
|
||||
{ value: 'priceAsc', label: 'قیمت صعودی' },
|
||||
{ value: 'priceDesc', label: 'قیمت نزولی' },
|
||||
];
|
||||
|
||||
function priceLabel(price?: number | null) {
|
||||
return Number(price || 0) === 0 ? "رایگان" : formatToman(price);
|
||||
}
|
||||
|
||||
export default function AdminEventsPage() {
|
||||
const AdminEventsPage: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [filters, setFilters] = React.useState({
|
||||
search: "",
|
||||
status: "all" as "all" | EventListItemSchema["status"],
|
||||
type: "all" as "all" | EventListItemSchema["event_type"],
|
||||
sort: "newest" as (typeof eventSortOptions)[number]["value"],
|
||||
search: '',
|
||||
status: 'all' as 'all' | EventListItemSchema['status'],
|
||||
type: 'all' as 'all' | EventListItemSchema['event_type'],
|
||||
sort: 'newest' as (typeof eventSortOptions)[number]['value'],
|
||||
});
|
||||
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["admin", "events", filters],
|
||||
queryKey: ['admin', 'events', filters],
|
||||
queryFn: () =>
|
||||
api.getEvents({
|
||||
statuses: filters.status === "all" ? undefined : [filters.status],
|
||||
event_type: filters.type === "all" ? undefined : filters.type,
|
||||
statuses:
|
||||
filters.status === 'all'
|
||||
? undefined
|
||||
: [filters.status as EventListItemSchema['status']],
|
||||
event_type:
|
||||
filters.type === 'all'
|
||||
? undefined
|
||||
: (filters.type as EventListItemSchema['event_type']),
|
||||
search: filters.search || undefined,
|
||||
limit: EVENTS_PAGE_SIZE,
|
||||
}),
|
||||
@@ -83,95 +72,39 @@ export default function AdminEventsPage() {
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (eventId: number) => api.deleteEvent(eventId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "events"] });
|
||||
toast({ title: "رویداد حذف شد", variant: "success" });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'events'] });
|
||||
toast({ title: 'رویداد حذف شد', variant: 'success' });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" });
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: resolveErrorMessage(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sortedEvents = React.useMemo(() => {
|
||||
const list = (eventsQuery.data ?? []).slice();
|
||||
switch (filters.sort) {
|
||||
case "newest":
|
||||
case 'newest':
|
||||
return list.sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime());
|
||||
case "oldest":
|
||||
case 'oldest':
|
||||
return list.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||
case "priceAsc":
|
||||
case 'priceAsc':
|
||||
return list.sort((a, b) => Number(a.price) - Number(b.price));
|
||||
case "priceDesc":
|
||||
case 'priceDesc':
|
||||
return list.sort((a, b) => Number(b.price) - Number(a.price));
|
||||
default:
|
||||
return list;
|
||||
}
|
||||
}, [eventsQuery.data, filters.sort]);
|
||||
|
||||
const renderEventActions = (event: EventListItemSchema) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)} aria-label="جزئیات">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>جزئیات</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" asChild aria-label="ویرایش">
|
||||
<Link to={`/admin/events/${event.id}/edit`}>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>ویرایش</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="icon" variant="destructive" disabled={deleteMutation.isPending} aria-label="حذف">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>حذف</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialogContent dir="rtl">
|
||||
<AlertDialogHeader className="text-right">
|
||||
<AlertDialogTitle>حذف رویداد</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
آیا از حذف رویداد «{event.title}» مطمئن هستید؟ این عملیات رویداد را از لیستهای عادی حذف میکند.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:justify-start">
|
||||
<AlertDialogCancel>انصراف</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteMutation.mutate(event.id)}
|
||||
>
|
||||
حذف
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-semibold">رویدادها</h2>
|
||||
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to="/admin/events/create">
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
افزودن رویداد
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-semibold">رویدادها</h2>
|
||||
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -179,23 +112,55 @@ export default function AdminEventsPage() {
|
||||
<CardTitle>فیلترها</CardTitle>
|
||||
<CardDescription>پیدا کردن سریع رویدادها</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Input
|
||||
placeholder="عنوان رویداد..."
|
||||
value={filters.search}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, search: event.target.value }))}
|
||||
/>
|
||||
<Select value={filters.status} onValueChange={(value) => setFilters((prev) => ({ ...prev, status: value as typeof filters.status }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: value as 'all' | EventListItemSchema['status'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{eventStatusOptions.find((option) => option.value === filters.status)?.label ||
|
||||
'وضعیت'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventStatusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.type} onValueChange={(value) => setFilters((prev) => ({ ...prev, type: value as typeof filters.type }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<Select
|
||||
value={filters.type}
|
||||
onValueChange={(value) =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
type: value as 'all' | EventListItemSchema['event_type'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{{
|
||||
all: 'همه انواع',
|
||||
online: 'آنلاین',
|
||||
on_site: 'حضوری',
|
||||
hybrid: 'ترکیبی',
|
||||
}[filters.type]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه انواع</SelectItem>
|
||||
<SelectItem value="online">آنلاین</SelectItem>
|
||||
@@ -203,11 +168,26 @@ export default function AdminEventsPage() {
|
||||
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.sort} onValueChange={(value) => setFilters((prev) => ({ ...prev, sort: value as typeof filters.sort }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<Select
|
||||
value={filters.sort}
|
||||
onValueChange={(value) =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: value as (typeof eventSortOptions)[number]['value'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{eventSortOptions.find((option) => option.value === filters.sort)?.label ||
|
||||
'مرتبسازی'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventSortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -226,75 +206,97 @@ export default function AdminEventsPage() {
|
||||
) : sortedEvents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">رویدادی یافت نشد.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto rounded-md border md:block">
|
||||
<table dir="rtl" className="w-full min-w-[860px] text-sm">
|
||||
<thead className="text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-36 px-3 py-2 text-right">پوستر</th>
|
||||
<th className="px-3 py-2 text-right">عنوان</th>
|
||||
<th className="px-3 py-2 text-right">وضعیت</th>
|
||||
<th className="px-3 py-2 text-right">تاریخ شروع</th>
|
||||
<th className="px-3 py-2 text-right">ثبتنامها</th>
|
||||
<th className="px-3 py-2 text-right">قیمت</th>
|
||||
<th className="px-3 py-2 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedEvents.map((event) => (
|
||||
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="px-3 py-2">
|
||||
<ProgressiveImage
|
||||
src={getEventCardImageUrl(event)}
|
||||
alt={event.title}
|
||||
wrapperClassName="aspect-video w-28 overflow-hidden rounded-lg bg-muted"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</td>
|
||||
<td className="cursor-pointer px-3 py-2 text-right font-medium" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||
{event.title}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Badge variant={statusConfig[event.status].variant}>{statusConfig[event.status].label}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{formatJalali(event.start_time)}</td>
|
||||
<td className="px-3 py-2 text-right">{toPersianDigits(event.registration_count)}</td>
|
||||
<td className="px-3 py-2 text-right">{priceLabel(event.price)}</td>
|
||||
<td className="px-3 py-2 text-left">{renderEventActions(event)}</td>
|
||||
<div className="space-y-4">
|
||||
<div className="hidden md:block">
|
||||
<ScrollArea className="rounded-md border">
|
||||
<table dir="rtl" className="w-full min-w-[780px] text-sm">
|
||||
<thead className="text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-right">پوستر</th>
|
||||
<th className="px-3 py-2 text-right">عنوان</th>
|
||||
<th className="px-3 py-2 text-right">وضعیت</th>
|
||||
<th className="px-3 py-2 text-right">تاریخ شروع</th>
|
||||
<th className="px-3 py-2 text-right">ثبتنامها</th>
|
||||
<th className="px-3 py-2 text-right">قیمت (تومان)</th>
|
||||
<th className="px-3 py-2 text-right">عملیات</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedEvents.map((event) => (
|
||||
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="px-3 py-2 text-right">
|
||||
<img
|
||||
src={getThumbUrl(event)}
|
||||
alt={event.title}
|
||||
className="h-12 w-12 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right cursor-pointer" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||
{event.title}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge variant={statusConfig[event.status].variant}>
|
||||
{statusConfig[event.status].label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{formatJalali(event.start_time)}</td>
|
||||
<td className="px-3 py-2 text-right">{toPersianDigits(event.registration_count)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatToman(event.price)}</td>
|
||||
<td className="px-3 py-2 text-left flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||
جزئیات
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/admin/events/${event.id}/edit`}>ویرایش</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate(event.id)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{sortedEvents.map((event) => (
|
||||
<div key={event.id} className="space-y-3 rounded-lg border bg-card p-3">
|
||||
<ProgressiveImage
|
||||
src={getEventCardImageUrl(event)}
|
||||
alt={event.title}
|
||||
wrapperClassName="aspect-video w-full overflow-hidden rounded-lg bg-muted"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<div key={event.id} className="rounded-lg border p-3 space-y-2 bg-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button className="text-right font-semibold" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||
{event.title}
|
||||
</button>
|
||||
<div className="font-semibold text-right">{event.title}</div>
|
||||
<Badge variant={statusConfig[event.status].variant}>{statusConfig[event.status].label}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-right text-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground text-right space-y-1">
|
||||
<div>تاریخ شروع: {formatJalali(event.start_time)}</div>
|
||||
<div>ثبتنامها: {toPersianDigits(event.registration_count)}</div>
|
||||
<div>قیمت: {priceLabel(event.price)}</div>
|
||||
<div>قیمت: {formatToman(event.price)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||
جزئیات
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/admin/events/${event.id}/edit`}>ویرایش</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(event.id)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
{renderEventActions(event)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default AdminEventsPage;
|
||||
|
||||
@@ -1,74 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Building2,
|
||||
CalendarDays,
|
||||
FileText,
|
||||
FolderTree,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
ShieldCheck,
|
||||
Tags,
|
||||
TicketPercent,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { Navigate, NavLink, useLocation } from "@/lib/router";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, NavLink, useLocation } from '@/lib/router';
|
||||
import { useMemo } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const navGroups = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "داشبورد",
|
||||
items: [
|
||||
{ to: "/admin/dashboard", label: "داشبورد", icon: LayoutDashboard, visibility: "staff" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
label: "کاربران",
|
||||
items: [
|
||||
{ to: "/admin/users", label: "کاربران", icon: UsersRound, visibility: "staff" },
|
||||
{ to: "/admin/universities", label: "دانشگاهها", icon: Building2, visibility: "staff" },
|
||||
{ to: "/admin/majors", label: "رشتهها", icon: GraduationCap, visibility: "staff" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
label: "رویدادها",
|
||||
items: [
|
||||
{ to: "/admin/events", label: "رویدادها", icon: CalendarDays, visibility: "staff" },
|
||||
{ to: "/admin/coupons", label: "کدهای تخفیف", icon: TicketPercent, visibility: "staff" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "blog",
|
||||
label: "بلاگ و دسترسی",
|
||||
items: [
|
||||
{ to: "/admin/blog", label: "نوشتههای بلاگ", icon: FileText, visibility: "blog" },
|
||||
{ to: "/admin/blog/categories", label: "دستهبندیها", icon: FolderTree, visibility: "taxonomy" },
|
||||
{ to: "/admin/blog/tags", label: "برچسبها", icon: Tags, visibility: "taxonomy" },
|
||||
{ to: "/admin/authorizations", label: "دسترسیها", icon: ShieldCheck, visibility: "superuser" },
|
||||
],
|
||||
},
|
||||
const navItems = [
|
||||
{ to: '/admin/users', label: 'مدیریت کاربران' },
|
||||
{ to: '/admin/events', label: 'مدیریت رویدادها' },
|
||||
] as const;
|
||||
|
||||
type NavItem = (typeof navGroups)[number]["items"][number];
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const canAccessAdmin = useMemo(
|
||||
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
|
||||
[isAuthenticated, user?.can_access_blog_admin, user?.is_staff, user?.is_superuser],
|
||||
const isAdmin = useMemo(
|
||||
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser),
|
||||
[isAuthenticated, user?.is_staff, user?.is_superuser],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -79,160 +26,38 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccessAdmin) {
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const canSeeItem = (item: NavItem) => {
|
||||
if (item.visibility === "staff") return Boolean(user?.is_staff || user?.is_superuser);
|
||||
if (item.visibility === "taxonomy") return Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
||||
if (item.visibility === "superuser") return Boolean(user?.is_superuser);
|
||||
return Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin);
|
||||
};
|
||||
|
||||
const visibleGroups = navGroups
|
||||
.map((group) => ({ ...group, items: group.items.filter(canSeeItem) }))
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
const isItemActive = (to: string) => {
|
||||
if (location.pathname === to) return true;
|
||||
if (to === "/admin/blog") {
|
||||
return /^\/admin\/blog\/(new|\d+)/.test(location.pathname ?? "");
|
||||
}
|
||||
if (to === "/admin/events") {
|
||||
return /^\/admin\/events(\/(create|\d+))?/.test(location.pathname ?? "");
|
||||
}
|
||||
return Boolean(location.pathname?.startsWith(`${to}/`));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/15" dir="rtl">
|
||||
<div className="flex min-h-screen">
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 lg:flex lg:flex-col",
|
||||
sidebarCollapsed ? "w-20" : "w-72",
|
||||
)}
|
||||
>
|
||||
<div className={cn("border-b p-4", sidebarCollapsed ? "text-center" : "text-right")}>
|
||||
<div className={cn("flex items-center gap-2", sidebarCollapsed ? "justify-center" : "justify-start")}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0 rounded-2xl"
|
||||
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||
aria-label={sidebarCollapsed ? "باز کردن منوی مدیریت" : "جمع کردن منوی مدیریت"}
|
||||
title={sidebarCollapsed ? "باز کردن منو" : "جمع کردن منو"}
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="border-b bg-muted/20">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-4 gap-4 flex-row-reverse md:flex-row">
|
||||
<h1 className="text-2xl font-bold">پنل مدیریت</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'rounded-full px-4 py-2 text-sm transition',
|
||||
(isActive || location.pathname?.startsWith(item.to))
|
||||
? 'bg-primary text-primary-foreground shadow'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground border',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelRightOpen className="h-4 w-4" /> : <PanelRightClose className="h-4 w-4" />}
|
||||
</Button>
|
||||
{!sidebarCollapsed ? (
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-3 p-3">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.key} className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
"px-3 py-2 text-xs font-semibold text-muted-foreground transition-opacity",
|
||||
sidebarCollapsed && "sr-only",
|
||||
)}
|
||||
>
|
||||
{group.label}
|
||||
</p>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isItemActive(item.to);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
className={cn(
|
||||
"flex items-center rounded-2xl px-3 py-3 text-sm transition",
|
||||
sidebarCollapsed ? "justify-center" : "gap-3",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
<span className={cn("font-medium", sidebarCollapsed && "sr-only")}>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="border-b bg-background/90 lg:hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="text-right">
|
||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
|
||||
</div>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2 rounded-2xl">
|
||||
<Menu className="h-4 w-4" />
|
||||
منو
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="max-h-[82vh] overflow-y-auto rounded-t-[2rem] border-t p-4 pb-[calc(env(safe-area-inset-bottom)+1rem)]"
|
||||
dir="rtl"
|
||||
>
|
||||
<SheetHeader className="mt-6 text-right">
|
||||
<SheetTitle>بخشهای پنل مدیریت</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-5 space-y-5">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.key} className="space-y-2">
|
||||
<p className="px-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isItemActive(item.to);
|
||||
return (
|
||||
<SheetClose asChild key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-2xl border px-3 py-3 text-sm transition",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground shadow"
|
||||
: "bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
</SheetClose>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto min-w-0 px-3 pb-8 pt-4 sm:px-4 lg:py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit3, Plus, Trash2 } from "lucide-react";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { MetaOptionSchema, MetaOptionWriteSchema } from "@/lib/types";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type Kind = "majors" | "universities";
|
||||
|
||||
const config = {
|
||||
majors: {
|
||||
title: "رشتهها",
|
||||
description: "مدیریت رشتههای قابل انتخاب کاربران",
|
||||
list: api.listAdminMajors.bind(api),
|
||||
create: api.createMajor.bind(api),
|
||||
update: api.updateMajor.bind(api),
|
||||
delete: api.deleteMajor.bind(api),
|
||||
},
|
||||
universities: {
|
||||
title: "دانشگاهها",
|
||||
description: "مدیریت دانشگاههای قابل انتخاب کاربران",
|
||||
list: api.listAdminUniversities.bind(api),
|
||||
create: api.createUniversity.bind(api),
|
||||
update: api.updateUniversity.bind(api),
|
||||
delete: api.deleteUniversity.bind(api),
|
||||
},
|
||||
};
|
||||
|
||||
export default function AdminMetaOptions({ kind }: { kind: Kind }) {
|
||||
const spec = config[kind];
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [editing, setEditing] = React.useState<MetaOptionSchema | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [form, setForm] = React.useState<MetaOptionWriteSchema>({ code: "", name: "" });
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", kind, debouncedSearch, page],
|
||||
queryFn: () => spec.list({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => (editing ? spec.update(editing.id, form) : spec.create(form)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", kind] });
|
||||
setOpen(false);
|
||||
toast({ title: "ذخیره شد", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => spec.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", kind] });
|
||||
toast({ title: "حذف شد", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm({ code: "", name: "" });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: MetaOptionSchema) => {
|
||||
setEditing(item);
|
||||
setForm({ code: item.code, name: item.label });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const items = query.data?.results ?? [];
|
||||
const count = query.data?.count ?? 0;
|
||||
const hasMore = page * PAGE_SIZE < count;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{spec.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{spec.description}</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>فهرست</CardTitle>
|
||||
<CardDescription>جستجو، ویرایش و حذف نرم موارد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="جستجو..."
|
||||
className="max-w-md"
|
||||
/>
|
||||
<div className="overflow-x-auto rounded-2xl border">
|
||||
<table className="w-full min-w-[520px] text-sm">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-right">نام</th>
|
||||
<th className="px-4 py-3 text-right">کد</th>
|
||||
<th className="px-4 py-3 text-right">کاربران</th>
|
||||
<th className="px-4 py-3 text-left"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{query.isLoading ? (
|
||||
<tr>
|
||||
<td className="px-4 py-6 text-center text-muted-foreground" colSpan={4}>در حال بارگذاری...</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-6 text-center text-muted-foreground" colSpan={4}>موردی یافت نشد.</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<tr key={item.id} className="border-t hover:bg-muted/40">
|
||||
<td className="px-4 py-3 font-medium">{item.label}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{item.code}</td>
|
||||
<td className="px-4 py-3">{formatNumberPersian(item.user_count ?? 0)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<ConfirmAction
|
||||
title="حذف مورد"
|
||||
description={`آیا از حذف «${item.label}» مطمئن هستید؟ این عملیات رکورد را از لیستهای عادی حذف میکند.`}
|
||||
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
trigger={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>صفحه {formatNumberPersian(page)} از {formatNumberPersian(Math.max(1, Math.ceil(count / PAGE_SIZE)))}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>قبلی</Button>
|
||||
<Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((current) => current + 1)}>بعدی</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent dir="rtl">
|
||||
<DialogHeader className="text-right">
|
||||
<DialogTitle>{editing ? "ویرایش" : "افزودن"} {spec.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>نام</Label>
|
||||
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>کد</Label>
|
||||
<Input dir="ltr" value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-start">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>انصراف</Button>
|
||||
<Button disabled={saveMutation.isPending || !form.name.trim() || !form.code.trim()} onClick={() => saveMutation.mutate()}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Mail, Phone, UserRound } from "lucide-react";
|
||||
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { UserListSchema, UserProfileSchema } from "@/lib/types";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatJalali, formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import type { UserListSchema } from '@/lib/types';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
formatJalali,
|
||||
formatNumberPersian,
|
||||
resolveErrorMessage,
|
||||
} from '@/lib/utils';
|
||||
|
||||
const USERS_PAGE_SIZE = 25;
|
||||
|
||||
function fullName(user: Pick<UserListSchema, "first_name" | "last_name" | "username">) {
|
||||
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
||||
}
|
||||
|
||||
function initials(user: Pick<UserListSchema, "first_name" | "last_name" | "username">) {
|
||||
const base = [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
||||
return base.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl bg-muted/35 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="text-left font-medium">{value || "—"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDetailDialog({
|
||||
user,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
user: UserProfileSchema | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
if (!user) return null;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto" dir="rtl">
|
||||
<DialogHeader className="text-right">
|
||||
<DialogTitle>جزئیات کاربر</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 rounded-2xl border bg-muted/20 p-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={user.profile_picture_thumbnail_url || user.profile_picture || undefined} />
|
||||
<AvatarFallback>{initials(user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 text-right">
|
||||
<h3 className="truncate text-lg font-bold">{fullName(user)}</h3>
|
||||
<p className="truncate text-sm text-muted-foreground">{user.username}</p>
|
||||
</div>
|
||||
<div className="mr-auto flex flex-wrap gap-2">
|
||||
<Badge variant={user.is_active ? "default" : "outline"}>{user.is_active ? "فعال" : "غیرفعال"}</Badge>
|
||||
{user.is_staff ? <Badge variant="secondary">Staff</Badge> : null}
|
||||
{user.is_superuser ? <Badge variant="destructive">Superuser</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<InfoRow label="نام" value={user.first_name} />
|
||||
<InfoRow label="نام خانوادگی" value={user.last_name} />
|
||||
<InfoRow label="موبایل" value={user.mobile} />
|
||||
<InfoRow label="ایمیل" value={user.email} />
|
||||
<InfoRow label="شماره دانشجویی" value={user.student_id} />
|
||||
<InfoRow label="سال ورود" value={user.year_of_study ? formatNumberPersian(user.year_of_study) : null} />
|
||||
<InfoRow label="دانشگاه" value={user.university} />
|
||||
<InfoRow label="رشته" value={user.major} />
|
||||
<InfoRow label="تاریخ عضویت" value={formatJalali(user.date_joined)} />
|
||||
<InfoRow label="حذف شده" value={user.is_deleted ? "بله" : "خیر"} />
|
||||
<InfoRow label="تایید موبایل" value={user.is_mobile_verified ? "بله" : "خیر"} />
|
||||
<InfoRow label="تایید ایمیل" value={user.is_email_verified ? "بله" : "خیر"} />
|
||||
<InfoRow label="دسترسی بلاگ" value={user.can_access_blog_admin ? "دارد" : "ندارد"} />
|
||||
<InfoRow label="نوشتن بلاگ" value={user.can_write_blog_posts ? "دارد" : "ندارد"} />
|
||||
<InfoRow label="بازبینی بلاگ" value={user.can_review_blog_posts ? "دارد" : "ندارد"} />
|
||||
<InfoRow label="اتصال گوگل" value={user.has_google_link ? "دارد" : "ندارد"} />
|
||||
</div>
|
||||
|
||||
{user.bio ? (
|
||||
<div className="rounded-2xl border bg-background p-4 text-right text-sm leading-7">
|
||||
<div className="mb-2 font-semibold">بیوگرافی</div>
|
||||
{user.bio}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const AdminUsersPage: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const [filters, setFilters] = React.useState({
|
||||
search: "",
|
||||
studentId: "",
|
||||
university: null as string | null,
|
||||
major: null as string | null,
|
||||
isActive: "all",
|
||||
search: '',
|
||||
studentId: '',
|
||||
university: 'all',
|
||||
major: 'all',
|
||||
isActive: 'all',
|
||||
});
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [selectedUserId, setSelectedUserId] = React.useState<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(filters.search.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [filters.search]);
|
||||
const majorsQuery = useQuery({
|
||||
queryKey: ['majors'],
|
||||
queryFn: () => api.getMajors(),
|
||||
});
|
||||
const universitiesQuery = useQuery({
|
||||
queryKey: ['universities'],
|
||||
queryFn: () => api.getUniversities(),
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["admin", "users", filters, debouncedSearch, page],
|
||||
queryKey: ['admin', 'users', filters, page],
|
||||
queryFn: () =>
|
||||
api.listUsers({
|
||||
search: debouncedSearch || undefined,
|
||||
search: filters.search || undefined,
|
||||
student_id: filters.studentId || undefined,
|
||||
university: filters.university || undefined,
|
||||
major: filters.major || undefined,
|
||||
university: filters.university === 'all' ? undefined : filters.university,
|
||||
major: filters.major === 'all' ? undefined : filters.major,
|
||||
is_active:
|
||||
filters.isActive === "all"
|
||||
filters.isActive === 'all'
|
||||
? undefined
|
||||
: filters.isActive === "active"
|
||||
? "true"
|
||||
: "false",
|
||||
: filters.isActive === 'active'
|
||||
? 'true'
|
||||
: 'false',
|
||||
limit: USERS_PAGE_SIZE,
|
||||
offset: (page - 1) * USERS_PAGE_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
const selectedUserQuery = useQuery({
|
||||
queryKey: ["admin", "users", selectedUserId, "detail"],
|
||||
queryFn: () => api.getUserDetail(selectedUserId as number),
|
||||
enabled: selectedUserId != null,
|
||||
});
|
||||
const users = usersQuery.data ?? [];
|
||||
const hasMore = users.length === USERS_PAGE_SIZE;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (usersQuery.error) {
|
||||
toast({
|
||||
title: "خطا در بارگذاری کاربران",
|
||||
title: 'خطا در بارگذاری کاربران',
|
||||
description: resolveErrorMessage(usersQuery.error),
|
||||
variant: "destructive",
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [usersQuery.error, toast]);
|
||||
|
||||
const users = usersQuery.data ?? [];
|
||||
const hasMore = users.length === USERS_PAGE_SIZE;
|
||||
|
||||
const handleFilterChange = (field: keyof typeof filters, value: string | null) => {
|
||||
setFilters((prev) => ({ ...prev, [field]: value ?? "" }));
|
||||
const handleFilterChange = (field: keyof typeof filters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [field]: value }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const loadMajors = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||
const data = await api.getMajorsPaged(params);
|
||||
return {
|
||||
count: data.count,
|
||||
results: data.results.map((item) => ({ value: item.code, label: item.label })),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadUniversities = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||
const data = await api.getUniversitiesPaged(params);
|
||||
return {
|
||||
count: data.count,
|
||||
results: data.results.map((item) => ({ value: item.code, label: item.label })),
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">کاربران</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">مدیریت و جستجوی کاربران سامانه</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">مدیریت و جستجوی کاربران سامانه</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>فیلترها</CardTitle>
|
||||
<CardDescription>جستجو با نام، ایمیل، موبایل، دانشگاه یا رشته</CardDescription>
|
||||
<CardDescription>جستجو و محدود کردن نتایج</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Input
|
||||
placeholder="نام، نامکاربری، ایمیل یا موبایل..."
|
||||
placeholder="نام، نامکاربری یا ایمیل..."
|
||||
value={filters.search}
|
||||
onChange={(event) => handleFilterChange("search", event.target.value)}
|
||||
onChange={(event) => handleFilterChange('search', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="شماره دانشجویی"
|
||||
value={filters.studentId}
|
||||
onChange={(event) => handleFilterChange("studentId", event.target.value)}
|
||||
onChange={(event) => handleFilterChange('studentId', event.target.value)}
|
||||
/>
|
||||
<Select value={filters.isActive} onValueChange={(value) => handleFilterChange("isActive", value)}>
|
||||
<Select value={filters.isActive} onValueChange={(value) => handleFilterChange('isActive', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<SelectValue placeholder="وضعیت">
|
||||
{{
|
||||
all: 'همه وضعیتها',
|
||||
active: 'فعال',
|
||||
inactive: 'غیرفعال',
|
||||
}[filters.isActive]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه وضعیتها</SelectItem>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
<SelectItem value="active">فعال</SelectItem>
|
||||
<SelectItem value="inactive">غیرفعال</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AsyncSearchableCombobox
|
||||
value={filters.university}
|
||||
onChange={(value) => handleFilterChange("university", value)}
|
||||
loadOptions={loadUniversities}
|
||||
placeholder="دانشگاه"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-xl">
|
||||
<AsyncSearchableCombobox
|
||||
value={filters.major}
|
||||
onChange={(value) => handleFilterChange("major", value)}
|
||||
loadOptions={loadMajors}
|
||||
placeholder="رشته"
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Select
|
||||
value={filters.university}
|
||||
onValueChange={(value) => handleFilterChange('university', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="دانشگاه">
|
||||
{filters.university === 'all'
|
||||
? 'همه'
|
||||
: universitiesQuery.data?.find((item) => item.code === filters.university)?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
{universitiesQuery.data?.map((item) => (
|
||||
<SelectItem key={item.code} value={item.code}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.major} onValueChange={(value) => handleFilterChange('major', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="رشته">
|
||||
{filters.major === 'all'
|
||||
? 'همه'
|
||||
: majorsQuery.data?.find((item) => item.code === filters.major)?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
{majorsQuery.data?.map((item) => (
|
||||
<SelectItem key={item.code} value={item.code}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="pb-0 md:pb-2">
|
||||
<CardTitle>لیست کاربران</CardTitle>
|
||||
<CardDescription>برای مشاهده جزئیات، روی هر ردیف کلیک کنید.</CardDescription>
|
||||
<CardDescription>نمایش کاربران مطابق فیلترهای انتخابی</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{usersQuery.isLoading ? (
|
||||
@@ -240,86 +185,91 @@ export default function AdminUsersPage() {
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">کاربری یافت نشد.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border">
|
||||
<table dir="rtl" className="w-full min-w-[620px] text-sm">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-right">کاربر</th>
|
||||
<th className="px-4 py-3 text-right">موبایل</th>
|
||||
<th className="px-4 py-3 text-right">ایمیل</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer border-t transition hover:bg-muted/50"
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") setSelectedUserId(user.id);
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-11 w-11">
|
||||
<AvatarImage src={user.profile_picture_thumbnail_url || user.profile_picture || undefined} />
|
||||
<AvatarFallback>{initials(user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 text-right">
|
||||
<div className="truncate font-semibold">{fullName(user)}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
{user.mobile || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
{user.email || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<div className="space-y-3">
|
||||
<ScrollArea className="rounded-md border hidden md:block">
|
||||
<table dir="rtl" className="w-full min-w-[700px] text-sm">
|
||||
<thead className="text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-right">نام کامل</th>
|
||||
<th className="px-3 py-2 text-right">نام کاربری</th>
|
||||
<th className="px-3 py-2 text-right">ایمیل</th>
|
||||
<th className="px-3 py-2 text-right">دانشگاه / گرایش</th>
|
||||
<th className="px-3 py-2 text-right">وضعیت</th>
|
||||
<th className="px-3 py-2 text-right">تاریخ عضویت</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="px-3 py-2 text-right">
|
||||
{(() => {
|
||||
const parts = [user.first_name, user.last_name].filter(Boolean);
|
||||
if (parts.length) return parts.join(' ');
|
||||
return user.username;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{user.username}</td>
|
||||
<td className="px-3 py-2 text-right">{user.email}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{user.major || '—'} · {user.university || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Badge variant={user.is_active ? 'default' : 'outline'}>
|
||||
{user.is_active ? 'فعال' : 'غیرفعال'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{formatJalali(user.date_joined)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="rounded-lg border p-3 space-y-2 bg-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-semibold text-right">{user.first_name || user.last_name ? `${user.first_name || ''} ${user.last_name || ''}`.trim() : user.username}</div>
|
||||
<Badge variant={user.is_active ? 'default' : 'outline'}>{user.is_active ? 'فعال' : 'غیرفعال'}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-right space-y-1">
|
||||
<div>نام کاربری: {user.username}</div>
|
||||
<div>ایمیل: {user.email}</div>
|
||||
<div>دانشگاه / گرایش: {user.university || '—'} · {user.major || '—'}</div>
|
||||
<div>تاریخ عضویت: {formatJalali(user.date_joined)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>صفحه {formatNumberPersian(page)}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((prev) => prev + 1)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!hasMore}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<UserDetailDialog
|
||||
user={selectedUserQuery.data ?? null}
|
||||
open={selectedUserId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedUserId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedUserQuery.isFetching && selectedUserId ? (
|
||||
<div className="fixed inset-x-0 bottom-24 z-50 mx-auto flex w-fit items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm shadow-lg">
|
||||
<UserRound className="h-4 w-4 animate-pulse" />
|
||||
در حال بارگذاری جزئیات...
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default AdminUsersPage;
|
||||
|
||||
1276
src/views/Auth.tsx
1276
src/views/Auth.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,453 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Filter, UserRound, X } from "lucide-react";
|
||||
import BlogThumbnail from "@/components/BlogThumbnail";
|
||||
import { BlogCardsSkeleton } from "@/components/page-loading";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { blogPostPath } from "@/lib/blog-routes";
|
||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { cn, formatJalaliDate, getBlogCardImageUrl } from "@/lib/utils";
|
||||
|
||||
type BlogProps = {
|
||||
initialPosts?: Types.PostListSchema[];
|
||||
initialSearch?: string;
|
||||
initialCategory?: string;
|
||||
initialTags?: string[];
|
||||
initialAuthors?: string[];
|
||||
banners?: Types.BlogBannerSchema[];
|
||||
filters?: Types.BlogFiltersSchema;
|
||||
};
|
||||
|
||||
function buildBlogPath(
|
||||
pathname: string,
|
||||
search: string,
|
||||
category: string,
|
||||
tags: string[],
|
||||
authors: string[],
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (category.trim()) params.set("category", category.trim());
|
||||
tags.forEach((tag) => params.append("tag", tag));
|
||||
authors.forEach((author) => params.append("author", author));
|
||||
if (search.trim()) params.set("search", search.trim());
|
||||
return params.size ? `${pathname}?${params.toString()}` : pathname;
|
||||
}
|
||||
|
||||
function BlogBannerSlider({ banners }: { banners: Types.BlogBannerSchema[] }) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (banners.length <= 1) return;
|
||||
const timer = window.setInterval(() => {
|
||||
setActiveIndex((index) => (index + 1) % banners.length);
|
||||
}, 6000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [banners.length]);
|
||||
|
||||
if (!banners.length) return null;
|
||||
|
||||
const activeBanner = banners[activeIndex] ?? banners[0];
|
||||
const goToPrevious = () => setActiveIndex((index) => (index - 1 + banners.length) % banners.length);
|
||||
const goToNext = () => setActiveIndex((index) => (index + 1) % banners.length);
|
||||
|
||||
return (
|
||||
<section className="mb-8 overflow-hidden rounded-[2rem] border border-border/70 bg-card">
|
||||
<a href={activeBanner.url} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={activeBanner.image_url}
|
||||
alt={activeBanner.alt_text || activeBanner.title || "بنر بلاگ"}
|
||||
className="aspect-[5/1.25] w-full object-cover md:aspect-[6/1.25]"
|
||||
/>
|
||||
</a>
|
||||
{banners.length > 1 ? (
|
||||
<div className="flex items-center justify-between gap-3 bg-background/80 px-4 py-3 backdrop-blur">
|
||||
<div className="flex gap-1.5">
|
||||
{banners.map((banner, index) => (
|
||||
<button
|
||||
key={banner.id}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
"h-2.5 rounded-full transition-all",
|
||||
index === activeIndex ? "w-8 bg-primary" : "w-2.5 bg-muted-foreground/30",
|
||||
)}
|
||||
aria-label={`نمایش بنر ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="icon" variant="ghost" className="rounded-full" onClick={goToNext}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="button" size="icon" variant="ghost" className="rounded-full" onClick={goToPrevious}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Blog({
|
||||
initialPosts = [],
|
||||
initialSearch = "",
|
||||
initialCategory = "",
|
||||
initialTags = [],
|
||||
initialAuthors = [],
|
||||
banners = [],
|
||||
filters = { categories: [], tags: [], authors: [] },
|
||||
}: BlogProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname || "/blog";
|
||||
const posts = initialPosts;
|
||||
const [searchDraft, setSearchDraft] = useState(initialSearch);
|
||||
const [selectedCategory, setSelectedCategory] = useState(initialCategory);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>(initialTags);
|
||||
const [selectedAuthors, setSelectedAuthors] = useState<string[]>(initialAuthors);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
() => new Set(initialCategory ? [initialCategory] : []),
|
||||
);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [listPending, setListPending] = useState(false);
|
||||
const [posts, setPosts] = useState<Types.PostListSchema[]>(initialPosts);
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const [loading, setLoading] = useState(!initialPosts.length && !initialSearch);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchDraft(initialSearch);
|
||||
setListPending(false);
|
||||
setPosts(initialPosts);
|
||||
}, [initialPosts]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCategory(initialCategory);
|
||||
if (initialCategory) {
|
||||
setExpandedCategories((current) => new Set([...current, initialCategory]));
|
||||
const loadPosts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getPosts({ search: search || undefined });
|
||||
setPosts(data);
|
||||
} catch (error) {
|
||||
console.error("Error loading posts:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setListPending(false);
|
||||
}, [initialCategory]);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTags(initialTags);
|
||||
setListPending(false);
|
||||
}, [initialTags]);
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAuthors(initialAuthors);
|
||||
setListPending(false);
|
||||
}, [initialAuthors]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
if (searchDraft.trim() !== initialSearch.trim()) {
|
||||
setListPending(true);
|
||||
navigate(
|
||||
buildBlogPath(pathname, searchDraft, selectedCategory, selectedTags, selectedAuthors),
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [initialSearch, navigate, pathname, searchDraft, selectedAuthors, selectedCategory, selectedTags]);
|
||||
|
||||
const navigateFilters = (next: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
authors?: string[];
|
||||
}) => {
|
||||
setListPending(true);
|
||||
navigate(
|
||||
buildBlogPath(
|
||||
pathname,
|
||||
next.search ?? searchDraft,
|
||||
next.category ?? selectedCategory,
|
||||
next.tags ?? selectedTags,
|
||||
next.authors ?? selectedAuthors,
|
||||
),
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const selectCategory = (slug: string) => {
|
||||
const nextCategory = selectedCategory === slug ? "" : slug;
|
||||
setSelectedCategory(nextCategory);
|
||||
navigateFilters({ category: nextCategory });
|
||||
};
|
||||
|
||||
const toggleCategoryExpanded = (slug: string) => {
|
||||
setExpandedCategories((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(slug)) {
|
||||
next.delete(slug);
|
||||
} else {
|
||||
next.add(slug);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTag = (slug: string) => {
|
||||
const nextTags = selectedTags.includes(slug)
|
||||
? selectedTags.filter((item) => item !== slug)
|
||||
: [...selectedTags, slug];
|
||||
setSelectedTags(nextTags);
|
||||
navigateFilters({ tags: nextTags });
|
||||
};
|
||||
|
||||
const toggleAuthor = (username: string) => {
|
||||
const nextAuthors = selectedAuthors.includes(username)
|
||||
? selectedAuthors.filter((item) => item !== username)
|
||||
: [...selectedAuthors, username];
|
||||
setSelectedAuthors(nextAuthors);
|
||||
navigateFilters({ authors: nextAuthors });
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchDraft("");
|
||||
setSelectedCategory("");
|
||||
setSelectedTags([]);
|
||||
setSelectedAuthors([]);
|
||||
setListPending(true);
|
||||
navigate(pathname, { replace: true });
|
||||
};
|
||||
|
||||
const renderCategoryTree = (categories: Types.BlogFilterCategory[], level = 0) => (
|
||||
<div className={level === 0 ? "space-y-2" : "mt-2 space-y-2"}>
|
||||
{categories.map((category) => {
|
||||
const active = selectedCategory === category.slug;
|
||||
const hasChildren = Boolean(category.children?.length);
|
||||
const expanded = expandedCategories.has(category.slug);
|
||||
return (
|
||||
<div key={category.id} style={{ paddingRight: `${level * 0.75}rem` }}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 rounded-2xl text-sm transition",
|
||||
active ? "bg-primary text-primary-foreground shadow-sm" : "bg-background/70 text-muted-foreground hover:bg-primary/10 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCategoryExpanded(category.slug)}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition hover:bg-current/10"
|
||||
aria-label={expanded ? "بستن زیر دستهها" : "نمایش زیر دستهها"}
|
||||
>
|
||||
<ChevronDown className={cn("h-4 w-4 transition", expanded && "rotate-180")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-8 w-8 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectCategory(category.slug)}
|
||||
className="min-w-0 flex-1 px-2 py-2 text-right"
|
||||
>
|
||||
<span className="line-clamp-1">{category.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded ? renderCategoryTree(category.children, level + 1) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const hasActiveFilters = Boolean(searchDraft || selectedCategory || selectedTags.length || selectedAuthors.length);
|
||||
|
||||
const filtersPanel = (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-right">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Filter className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-bold">فیلترهای بلاگ</h2>
|
||||
</div>
|
||||
</div>
|
||||
{hasActiveFilters ? (
|
||||
<Button type="button" variant="ghost" size="sm" className="gap-2 rounded-full" onClick={clearFilters}>
|
||||
<X className="h-4 w-4" />
|
||||
پاک کردن
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-border/60 bg-muted/20 p-3">
|
||||
<h3 className="mb-3 text-right text-sm font-semibold">دستهبندیها</h3>
|
||||
{filters.categories.length ? renderCategoryTree(filters.categories) : (
|
||||
<p className="text-right text-xs text-muted-foreground">دستهای برای فیلتر وجود ندارد.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-3xl border border-border/60 bg-muted/20 p-3">
|
||||
<h3 className="mb-3 text-right text-sm font-semibold">موضوعات</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.tags.map((tag) => {
|
||||
const active = selectedTags.includes(tag.slug);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.slug)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 text-xs transition",
|
||||
active ? "bg-primary text-primary-foreground" : "bg-background text-muted-foreground hover:bg-primary/10 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!filters.tags.length ? <p className="text-xs text-muted-foreground">موضوعی برای فیلتر وجود ندارد.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-border/60 bg-muted/20 p-3">
|
||||
<h3 className="mb-3 text-right text-sm font-semibold">نویسندگان</h3>
|
||||
<div className="space-y-2">
|
||||
{filters.authors.map((author) => {
|
||||
const active = selectedAuthors.includes(author.username);
|
||||
const name = [author.first_name, author.last_name].filter(Boolean).join(" ") || author.username;
|
||||
return (
|
||||
<button
|
||||
key={author.id}
|
||||
type="button"
|
||||
onClick={() => toggleAuthor(author.username)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-start gap-2 rounded-2xl px-3 py-2 text-right text-sm transition",
|
||||
active ? "bg-primary text-primary-foreground" : "bg-background text-muted-foreground hover:bg-primary/10 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
<span className="min-w-0 flex-1 truncate">{name}</span>
|
||||
<span className="rounded-full bg-current/10 px-2 py-0.5 text-xs">{author.post_count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!filters.authors.length ? <p className="text-right text-xs text-muted-foreground">نویسندهای برای فیلتر وجود ندارد.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) {
|
||||
params.set("search", search.trim());
|
||||
}
|
||||
const basePath = location.pathname || "/blog";
|
||||
const nextPath = params.size
|
||||
? `${basePath}?${params.toString()}`
|
||||
: basePath;
|
||||
navigate(nextPath, { replace: true });
|
||||
}, [location.pathname, navigate, search]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.32))]" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<BlogBannerSlider banners={banners} />
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold mb-8">وبلاگ</h1>
|
||||
|
||||
<div className="mb-8 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
|
||||
<div className="text-right">
|
||||
<p className="mb-2 text-sm font-medium text-primary">خواندنیهای انجمن</p>
|
||||
<h1 className="text-4xl font-black tracking-tight">بلاگ</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||
نوشتههای آموزشی، تجربههای دانشجویی و یادداشتهای تخصصی اعضای انجمن علمی.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full max-w-md items-center gap-2">
|
||||
<div className="mb-8">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="جستجو در نوشتهها..."
|
||||
value={searchDraft}
|
||||
onChange={(event) => setSearchDraft(event.target.value)}
|
||||
className="h-12 flex-1 rounded-2xl bg-background/80 text-right shadow-sm backdrop-blur"
|
||||
placeholder="جستجو در مقالات..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="باز کردن فیلترهای بلاگ"
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
className="h-12 w-12 shrink-0 rounded-2xl border-border/70 bg-card/80 shadow-sm backdrop-blur xl:hidden"
|
||||
>
|
||||
<Filter className="h-5 w-5 text-primary" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:hidden">
|
||||
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="hidden h-12 w-full justify-between rounded-[1.5rem] border-border/70 bg-card/80 px-4 shadow-sm backdrop-blur"
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2 font-bold">
|
||||
<Filter className="h-5 w-5 text-primary" />
|
||||
فیلترهای بلاگ
|
||||
</span>
|
||||
<ChevronDown className={cn("h-4 w-4 transition", mobileFiltersOpen && "rotate-180")} />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[85vh] rounded-t-[2rem]" dir="rtl">
|
||||
<DrawerTitle className="sr-only">فیلترهای بلاگ</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
انتخاب دستهبندی، موضوع و نویسنده برای فیلتر کردن نوشتههای بلاگ
|
||||
</DrawerDescription>
|
||||
<div className="overflow-y-auto px-4 pb-4 pt-2">
|
||||
{filtersPanel}
|
||||
<DrawerClose asChild>
|
||||
<Button type="button" variant="outline" className="mt-4 w-full rounded-full">
|
||||
بستن
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[18rem_minmax(0,1fr)] xl:items-start">
|
||||
<aside className="hidden xl:block">
|
||||
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto rounded-[2rem] border border-border/70 bg-card/80 p-4 shadow-sm backdrop-blur">
|
||||
{filtersPanel}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{listPending ? (
|
||||
<BlogCardsSkeleton />
|
||||
) : posts.length === 0 ? (
|
||||
<p className="rounded-3xl border border-dashed bg-background/70 p-10 text-center text-muted-foreground">
|
||||
نوشتهای پیدا نشد.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={blogPostPath(post.slug)}
|
||||
className="group overflow-hidden rounded-[2rem] border border-border/70 bg-card/85 shadow-sm transition duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-primary/10"
|
||||
>
|
||||
<BlogThumbnail
|
||||
post={post}
|
||||
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
|
||||
className="aspect-[16/10] rounded-t-[2rem]"
|
||||
/>
|
||||
<article className="space-y-4 p-5 text-right">
|
||||
<h2 className="line-clamp-2 text-xl font-bold leading-9 transition group-hover:text-primary">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="line-clamp-3 min-h-[5.25rem] text-sm leading-7 text-muted-foreground">
|
||||
{post.excerpt || post.seo_description || "خلاصهای برای این نوشته ثبت نشده است."}
|
||||
{loading ? (
|
||||
<p className="text-center text-muted-foreground">در حال بارگذاری...</p>
|
||||
) : posts.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">مقالهای یافت نشد</p>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{posts.map((post) => (
|
||||
<Link key={post.id} to={`/blog/${post.slug}`}>
|
||||
<Card className="h-full hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-2">{post.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{post.category?.name && (
|
||||
<span className="text-primary ml-2">{post.category.name}</span>
|
||||
)}
|
||||
{new Date(post.created_at).toLocaleDateString("fa-IR")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{post.excerpt && (
|
||||
<p className="text-muted-foreground line-clamp-3 mb-4">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
نویسنده: {post.author.first_name} {post.author.last_name}
|
||||
</p>
|
||||
<time className="block text-xs font-medium text-primary/80" dateTime={post.published_at || post.created_at}>
|
||||
{formatJalaliDate(post.published_at || post.created_at)}
|
||||
</time>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,6 @@ import type * as Types from "@/lib/types";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import GalleryLightbox, { type GalleryLightboxItem } from "@/components/GalleryLightbox";
|
||||
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import CouponDialogFa from "@/components/CouponDialogFa";
|
||||
@@ -17,12 +15,7 @@ import {
|
||||
formatJalali,
|
||||
formatNumberPersian,
|
||||
formatToman,
|
||||
getEventCardImageUrl,
|
||||
getEventHeroImageUrl,
|
||||
getEventSeoImageUrl,
|
||||
getGalleryImageBlurUrl,
|
||||
getGalleryImageFullUrl,
|
||||
getGalleryImagePreviewUrl,
|
||||
getThumbUrl,
|
||||
resolveErrorMessage,
|
||||
toPersianDigits,
|
||||
} from "@/lib/utils";
|
||||
@@ -69,9 +62,8 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
|
||||
const [event, setEvent] = useState<Types.EventDetailSchema | null>(initialEvent);
|
||||
const [eventThumb, setEventThumb] = useState<string | null>(
|
||||
initialEvent ? getEventCardImageUrl(initialEvent) : null,
|
||||
initialEvent ? getThumbUrl(initialEvent) : null,
|
||||
);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(!initialEvent);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -102,7 +94,7 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
|
||||
const canonicalUrl = event ? `${siteUrl}/events/${event.slug}` : `${siteUrl}/events`;
|
||||
const primaryImage = event
|
||||
? toAbsoluteUrl(getEventSeoImageUrl(event)) ?? `${siteUrl}/favicon.ico`
|
||||
? toAbsoluteUrl(getThumbUrl(event)) ?? `${siteUrl}/favicon.ico`
|
||||
: `${siteUrl}/favicon.ico`;
|
||||
const pageTitle = event ? `${event.title} | ${siteName}` : `جزئیات رویداد | ${siteName}`;
|
||||
const pageDescription = sanitizeDescription(event?.description);
|
||||
@@ -265,14 +257,14 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
|
||||
if (initialEvent && initialEvent.slug === slug) {
|
||||
setEvent(initialEvent);
|
||||
setEventThumb(getEventCardImageUrl(initialEvent));
|
||||
setEventThumb(getThumbUrl(initialEvent));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await api.getEventBySlug(slug);
|
||||
if (cancelled) return;
|
||||
setEvent(data);
|
||||
setEventThumb(getEventCardImageUrl(data));
|
||||
setEventThumb(getThumbUrl(data));
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return;
|
||||
toast({
|
||||
@@ -343,19 +335,6 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
return { registrationOpen, remaining, full };
|
||||
}, [deadlineTs, event, nowTs, rsTs]);
|
||||
|
||||
const galleryItems = useMemo<GalleryLightboxItem[]>(
|
||||
() =>
|
||||
(event?.gallery_images ?? []).map((image) => ({
|
||||
id: image.id,
|
||||
alt: image.title || event?.title || "تصویر رویداد",
|
||||
title: image.title,
|
||||
previewSrc: getGalleryImagePreviewUrl(image),
|
||||
blurSrc: getGalleryImageBlurUrl(image),
|
||||
fullSrc: getGalleryImageFullUrl(image),
|
||||
})),
|
||||
[event],
|
||||
);
|
||||
|
||||
const eventStructuredData = useMemo(() => {
|
||||
if (!event) return null;
|
||||
|
||||
@@ -522,12 +501,12 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||
<ProgressiveImage
|
||||
src={getEventHeroImageUrl(event)}
|
||||
blurSrc={event.absolute_featured_image_thumbnail_url}
|
||||
<img
|
||||
src={getThumbUrl(event)}
|
||||
alt={event.title}
|
||||
wrapperClassName="h-full w-full"
|
||||
className="h-full w-full object-cover"
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -550,25 +529,17 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{galleryItems.length ? (
|
||||
{event.gallery_images?.length ? (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-3">گالری تصاویر</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{galleryItems.map((image, index) => (
|
||||
<button
|
||||
{event.gallery_images.map((image) => (
|
||||
<img
|
||||
key={image.id}
|
||||
type="button"
|
||||
className="overflow-hidden rounded-md text-right transition-transform hover:scale-[1.01] focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
onClick={() => setLightboxIndex(index)}
|
||||
>
|
||||
<ProgressiveImage
|
||||
src={image.previewSrc}
|
||||
blurSrc={image.blurSrc}
|
||||
alt={image.alt}
|
||||
wrapperClassName="h-36 w-full"
|
||||
className="h-36 w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
src={image.absolute_image_url || ""}
|
||||
alt={image.title || ""}
|
||||
className="w-full h-36 object-cover rounded-md"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -637,17 +608,6 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GalleryLightbox
|
||||
items={galleryItems}
|
||||
open={lightboxIndex !== null}
|
||||
initialIndex={lightboxIndex ?? 0}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setLightboxIndex(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalali, formatNumberPersian, formatToman, getEventCardImageUrl } from "@/lib/utils";
|
||||
import { formatJalali, formatNumberPersian, formatToman, getThumbUrl } from "@/lib/utils";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
type EventsProps = {
|
||||
@@ -74,7 +73,7 @@ export default function Events({
|
||||
|
||||
const ogImage = useMemo(() => {
|
||||
if (!events.length) return `${siteUrl}/favicon.ico`;
|
||||
return toAbsoluteUrl(getEventCardImageUrl(events[0])) ?? `${siteUrl}/favicon.ico`;
|
||||
return toAbsoluteUrl(getThumbUrl(events[0])) ?? `${siteUrl}/favicon.ico`;
|
||||
}, [events]);
|
||||
|
||||
const listStructuredData = useMemo(() => {
|
||||
@@ -94,7 +93,7 @@ export default function Events({
|
||||
listItem.endDate = eventItem.end_time;
|
||||
}
|
||||
|
||||
const imageUrl = toAbsoluteUrl(getEventCardImageUrl(eventItem));
|
||||
const imageUrl = toAbsoluteUrl(getThumbUrl(eventItem));
|
||||
if (imageUrl) {
|
||||
listItem.image = imageUrl;
|
||||
}
|
||||
@@ -211,11 +210,12 @@ export default function Events({
|
||||
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
|
||||
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
|
||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||
<ProgressiveImage
|
||||
src={getEventCardImageUrl(event)}
|
||||
<img
|
||||
src={getThumbUrl(event)}
|
||||
alt={event.title}
|
||||
wrapperClassName="h-full w-full"
|
||||
className="h-full w-full object-cover"
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Loader2 } from "lucide-react";
|
||||
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||
import OtpCodeField from "@/components/OtpCodeField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/lib/api";
|
||||
import { Link, useNavigate, useSearchParams } from "@/lib/router";
|
||||
import type { GoogleFlowResponseSchema } from "@/lib/types";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
type CallbackStep = "loading" | "collect_profile" | "claim_required" | "error";
|
||||
|
||||
const normalizeDigits = (value: string) =>
|
||||
value
|
||||
.replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0))
|
||||
.replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660));
|
||||
const sanitizeMobile = (value: string) => normalizeDigits(value).replace(/[^\d]/g, "");
|
||||
const sanitizeUsername = (value: string) => value.replace(/[^A-Za-z0-9._-]/g, "");
|
||||
|
||||
export default function GoogleAuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { setSessionTokens } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const flow = searchParams.get("flow") || "";
|
||||
const [step, setStep] = useState<CallbackStep>("loading");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [otpCooldown, setOtpCooldown] = useState(0);
|
||||
const [googleFlow, setGoogleFlow] = useState<GoogleFlowResponseSchema | null>(null);
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
mobile: "",
|
||||
username: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
student_id: "",
|
||||
year_of_study: "",
|
||||
major: null as string | null,
|
||||
university: null as string | null,
|
||||
});
|
||||
const [claimCode, setClaimCode] = useState("");
|
||||
|
||||
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||
const data = await api.getMajorsPaged(params);
|
||||
return {
|
||||
count: data.count,
|
||||
results: data.results.map((major) => ({ value: String(major.code), label: major.label })),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||
const data = await api.getUniversitiesPaged(params);
|
||||
return {
|
||||
count: data.count,
|
||||
results: data.results.map((university) => ({ value: String(university.code), label: university.label })),
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (otpCooldown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setOtpCooldown((current) => current - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [otpCooldown]);
|
||||
|
||||
const handleAuthenticatedFlow = useCallback(async (payload: GoogleFlowResponseSchema) => {
|
||||
if (!payload.access_token || !payload.refresh_token) {
|
||||
throw new Error("توکنهای ورود گوگل دریافت نشد.");
|
||||
}
|
||||
await setSessionTokens(payload.access_token, payload.refresh_token);
|
||||
toast({
|
||||
title: "ورود با گوگل کامل شد",
|
||||
description: "حساب شما با موفقیت بازیابی یا متصل شد.",
|
||||
variant: "success",
|
||||
});
|
||||
navigate("/profile", { replace: true });
|
||||
}, [navigate, setSessionTokens, toast]);
|
||||
|
||||
const applyFlow = useCallback(async (payload: GoogleFlowResponseSchema) => {
|
||||
setGoogleFlow(payload);
|
||||
if (payload.status === "authenticated") {
|
||||
await handleAuthenticatedFlow(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.status === "claim_required") {
|
||||
setProfileForm((current) => ({
|
||||
...current,
|
||||
mobile: payload.mobile || current.mobile,
|
||||
}));
|
||||
setStep("claim_required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.status === "collect_profile") {
|
||||
setProfileForm((current) => ({
|
||||
...current,
|
||||
mobile: payload.mobile || current.mobile,
|
||||
first_name: payload.first_name || current.first_name,
|
||||
last_name: payload.last_name || current.last_name,
|
||||
}));
|
||||
setStep("collect_profile");
|
||||
return;
|
||||
}
|
||||
|
||||
setStep("error");
|
||||
}, [handleAuthenticatedFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!flow) {
|
||||
setStep("error");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadFlow = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = await api.getGoogleFlow(flow);
|
||||
if (!cancelled) {
|
||||
await applyFlow(payload);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
toast({
|
||||
title: "اتصال گوگل کامل نشد",
|
||||
description: resolveErrorMessage(error, "لینک گوگل نامعتبر یا منقضی شده است."),
|
||||
variant: "destructive",
|
||||
});
|
||||
setStep("error");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void loadFlow();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyFlow, flow, toast]);
|
||||
|
||||
const handleCompleteProfile = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setLoading(true);
|
||||
const payload = await api.completeGoogleSignup({
|
||||
flow,
|
||||
mobile: sanitizeMobile(profileForm.mobile),
|
||||
username: googleFlow?.resolution === "new_account" ? profileForm.username : undefined,
|
||||
student_id: profileForm.student_id || undefined,
|
||||
year_of_study: profileForm.year_of_study ? Number(profileForm.year_of_study) : undefined,
|
||||
major: profileForm.major || undefined,
|
||||
university: profileForm.university || undefined,
|
||||
first_name: profileForm.first_name || undefined,
|
||||
last_name: profileForm.last_name || undefined,
|
||||
});
|
||||
if (payload.status === "claim_required") {
|
||||
setOtpCooldown(120);
|
||||
}
|
||||
await applyFlow(payload);
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "تکمیل ورود با گوگل ناموفق بود",
|
||||
description: resolveErrorMessage(error, "اطلاعات تکمیلی قابل پذیرش نیست."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendClaimOtp = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.resendGoogleClaimOtp(flow);
|
||||
setOtpCooldown(120);
|
||||
toast({
|
||||
title: "کد پیامکی دوباره ارسال شد",
|
||||
description: "کد تازه را وارد کنید تا فرایند اتصال حساب کامل شود.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "ارسال مجدد ناموفق بود",
|
||||
description: resolveErrorMessage(error, "امکان ارسال دوباره کد وجود ندارد."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyClaim = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setLoading(true);
|
||||
const payload = await api.verifyGoogleClaim(flow, normalizeDigits(claimCode));
|
||||
await applyFlow(payload);
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "تأیید حساب ناموفق بود",
|
||||
description: resolveErrorMessage(error, "کد پیامکی معتبر نیست."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title =
|
||||
step === "claim_required"
|
||||
? "تأیید شماره موبایل"
|
||||
: step === "collect_profile"
|
||||
? "تکمیل اطلاعات حساب"
|
||||
: step === "error"
|
||||
? "ورود با گوگل متوقف شد"
|
||||
: "در حال بازیابی حساب";
|
||||
|
||||
const description =
|
||||
step === "claim_required"
|
||||
? "برای اتصال نهایی حساب، مالکیت شماره موبایل را با کد پیامکی تأیید کنید."
|
||||
: step === "collect_profile"
|
||||
? googleFlow?.resolution === "existing_email_claim"
|
||||
? "ایمیل گوگل شما با یک حساب قدیمی تطابق دارد. موبایل همان حساب را تأیید کنید تا بازیابی کامل شود."
|
||||
: "این اولین ورود شما با گوگل است. اطلاعات تکمیلی را ثبت کنید تا حساب شما ساخته شود."
|
||||
: step === "error"
|
||||
? "لینک این فرایند منقضی شده یا از سمت گوگل کامل نشده است."
|
||||
: "چند لحظه صبر کنید تا وضعیت حساب گوگل شما بررسی شود.";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-10" dir="rtl">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Card className="border-border/70 bg-background/90 shadow-xl">
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription className="leading-7">{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{step === "loading" ? (
|
||||
<div className="flex items-center justify-center gap-3 rounded-[1.5rem] border border-border/70 bg-muted/20 px-4 py-10 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
در حال دریافت وضعیت حساب گوگل...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "collect_profile" ? (
|
||||
<form className="space-y-5" onSubmit={handleCompleteProfile}>
|
||||
{googleFlow?.email ? (
|
||||
<div className="rounded-[1.5rem] border border-primary/15 bg-primary/5 p-4 text-right text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">ایمیل متصل به گوگل</p>
|
||||
<p className="mt-2">{googleFlow.email}</p>
|
||||
{googleFlow.mobile_hint ? (
|
||||
<p className="mt-3 text-xs">
|
||||
راهنما: حساب قدیمی با موبایل {googleFlow.mobile_hint} شناخته شده است.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">نام</Label>
|
||||
<Input
|
||||
value={profileForm.first_name}
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, first_name: event.target.value }))}
|
||||
className="h-12 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">نام خانوادگی</Label>
|
||||
<Input
|
||||
value={profileForm.last_name}
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, last_name: event.target.value }))}
|
||||
className="h-12 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">شماره موبایل</Label>
|
||||
<Input
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
value={profileForm.mobile}
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, mobile: sanitizeMobile(event.target.value) }))}
|
||||
className="h-12 rounded-2xl"
|
||||
placeholder="09xxxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
{googleFlow?.resolution === "new_account" ? (
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">نام کاربری</Label>
|
||||
<Input
|
||||
dir="ltr"
|
||||
value={profileForm.username}
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, username: sanitizeUsername(event.target.value) }))}
|
||||
className="h-12 rounded-2xl"
|
||||
placeholder="latin.username"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{googleFlow?.resolution === "new_account" ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">دانشگاه</Label>
|
||||
<AsyncSearchableCombobox
|
||||
loadOptions={loadUniversities}
|
||||
value={profileForm.university}
|
||||
onChange={(value) => setProfileForm((current) => ({ ...current, university: value }))}
|
||||
placeholder="انتخاب دانشگاه"
|
||||
searchPlaceholder="نام دانشگاه را بنویسید..."
|
||||
emptyText="دانشگاهی پیدا نشد"
|
||||
className="h-12 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">رشته تحصیلی</Label>
|
||||
<AsyncSearchableCombobox
|
||||
loadOptions={loadMajors}
|
||||
value={profileForm.major}
|
||||
onChange={(value) => setProfileForm((current) => ({ ...current, major: value }))}
|
||||
placeholder="انتخاب رشته"
|
||||
searchPlaceholder="نام رشته را بنویسید..."
|
||||
emptyText="رشتهای پیدا نشد"
|
||||
className="h-12 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">شماره دانشجویی (اختیاری)</Label>
|
||||
<Input
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
value={profileForm.student_id}
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, student_id: sanitizeMobile(event.target.value) }))}
|
||||
className="h-12 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">سال ورودی (اختیاری)</Label>
|
||||
<Input
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
value={profileForm.year_of_study}
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, year_of_study: sanitizeMobile(event.target.value) }))}
|
||||
className="h-12 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-[1.5rem] border border-amber-400/30 bg-amber-500/10 p-4 text-right text-sm leading-7 text-amber-900 dark:text-amber-100">
|
||||
این مسیر برای بازیابی حساب قدیمی است. پس از ثبت موبایل، یک کد پیامکی ارسال میشود تا مالکیت حساب تأیید شود.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="h-12 w-full rounded-2xl" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال ادامه...
|
||||
</>
|
||||
) : (
|
||||
"ادامه و دریافت کد تأیید"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === "claim_required" ? (
|
||||
<form className="space-y-5" onSubmit={handleVerifyClaim}>
|
||||
<div className="rounded-[1.5rem] border border-primary/15 bg-primary/5 p-4 text-right text-sm leading-7 text-muted-foreground">
|
||||
<p className="font-medium text-foreground">کد به این شماره ارسال شده است</p>
|
||||
<p className="mt-2">{googleFlow?.mobile_hint || googleFlow?.mobile || profileForm.mobile}</p>
|
||||
{googleFlow?.resolution === "new_account" ? (
|
||||
<p className="mt-3 text-xs">
|
||||
بعد از تأیید این کد، حساب جدید شما ساخته میشود و مستقیماً وارد سایت میشوید.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-xs">
|
||||
بعد از تأیید این کد، حساب قدیمی شما به گوگل متصل میشود و بازیابی کامل خواهد شد.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block text-right">کد پیامکی</Label>
|
||||
<OtpCodeField value={claimCode} onChange={(value) => setClaimCode(normalizeDigits(value))} disabled={loading} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row-reverse">
|
||||
<Button type="submit" className="h-12 flex-1 rounded-2xl" disabled={loading || claimCode.length !== 5}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال تأیید...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="ml-2 h-4 w-4" />
|
||||
تأیید و ورود
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-2xl"
|
||||
onClick={() => void handleResendClaimOtp()}
|
||||
disabled={loading || otpCooldown > 0}
|
||||
>
|
||||
{otpCooldown > 0 ? `ارسال مجدد تا ${otpCooldown} ثانیه` : "ارسال دوباره کد"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === "error" ? (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-[1.5rem] border border-amber-400/30 bg-amber-500/10 p-4 text-right text-sm leading-7 text-amber-900 dark:text-amber-100">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">فرایند گوگل کامل نشد</p>
|
||||
<p className="mt-2">
|
||||
دوباره از صفحه ورود شروع کنید. اگر مشکل ادامه داشت، از ورود با موبایل یا بازیابی با موبایل استفاده کنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button type="button" className="h-12 rounded-2xl" onClick={() => void api.startGoogleLogin()}>
|
||||
شروع دوباره با گوگل
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="h-12 rounded-2xl">
|
||||
<Link to="/auth">
|
||||
<ArrowLeft className="ml-2 h-4 w-4" />
|
||||
بازگشت به ورود
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,77 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowLeft, Loader2, ShieldAlert } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from '@/lib/router';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveErrorMessage } from '@/lib/utils';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function ResetPasswordConfirm() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["legacy-reset-guidance", token],
|
||||
queryFn: () => api.getLegacyResetTokenMessage(token || ""),
|
||||
retry: false,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const message = isError
|
||||
? resolveErrorMessage(error, "این مسیر دیگر برای بازیابی رمز عبور فعال نیست.")
|
||||
: data?.message ||
|
||||
"لینک بازیابی ایمیلی غیرفعال شده است. برای ادامه از بازیابی با موبایل یا ورود با گوگل استفاده کنید.";
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) {
|
||||
toast({ title: 'توکن نامعتبر است', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
toast({ title: 'رمز عبور کوتاه است', description: 'حداقل ۸ کاراکتر', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
toast({ title: 'عدم تطابق', description: 'تکرار رمز با رمز جدید یکسان نیست', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.resetPasswordConfirm(token, password);
|
||||
toast({ title: 'رمز عبور با موفقیت تغییر کرد', variant: 'success' });
|
||||
navigate('/auth');
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: resolveErrorMessage(error, 'مشکلی رخ داد'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-10" dir="rtl">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Card className="border-border/70 bg-background/90 shadow-xl">
|
||||
<CardHeader className="text-right">
|
||||
<div className="mb-4 inline-flex rounded-2xl border border-amber-400/30 bg-amber-500/10 p-3 text-amber-700 dark:text-amber-100">
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" dir="rtl">
|
||||
<CardHeader>
|
||||
<CardTitle>تعیین رمز جدید</CardTitle>
|
||||
<CardDescription>رمز عبور جدید را وارد کنید</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="password">رمز عبور جدید</Label>
|
||||
<Input id="password" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<CardTitle>لینک بازیابی قدیمی غیرفعال شده است</CardTitle>
|
||||
<CardDescription className="leading-7">
|
||||
مسیرهای مبتنی بر ایمیل دیگر برای بازیابی حساب استفاده نمیشوند.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 text-right">
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-muted/25 p-4 text-sm leading-7 text-muted-foreground">
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-end gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
در حال دریافت راهنمای بازیابی...
|
||||
</span>
|
||||
) : (
|
||||
message
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="confirm">تکرار رمز</Label>
|
||||
<Input id="confirm" type="password" required value={confirm} onChange={(e) => setConfirm(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-amber-400/30 bg-amber-500/10 p-4 text-sm leading-7 text-amber-900 dark:text-amber-100">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">راه جایگزین</p>
|
||||
<p className="mt-2">
|
||||
از صفحه بازیابی با موبایل استفاده کنید. اگر موبایل ثبتشده را هم در دسترس ندارید، ورود با گوگل و همان ایمیل قبلی بهترین مسیر بازیابی است.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button asChild className="h-12 rounded-2xl">
|
||||
<Link to="/reset-password">بازیابی با موبایل</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="h-12 rounded-2xl">
|
||||
<Link to="/auth">
|
||||
<ArrowLeft className="ml-2 h-4 w-4" />
|
||||
بازگشت به ورود
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'در حال ثبت...' : 'ثبت رمز جدید'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,103 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertTriangle, Loader2, ShieldCheck } from "lucide-react";
|
||||
import OtpCodeField from "@/components/OtpCodeField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/lib/api";
|
||||
import { Link } from "@/lib/router";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
const normalizeDigits = (value: string) =>
|
||||
value
|
||||
.replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0))
|
||||
.replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660));
|
||||
const sanitizeMobile = (value: string) => normalizeDigits(value).replace(/[^\d]/g, "");
|
||||
import { useState } from 'react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveErrorMessage } from '@/lib/utils';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function ResetPasswordRequest() {
|
||||
const { toast } = useToast();
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setCooldown((current) => current - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
const handleSendOtp = async () => {
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.sendOtp({
|
||||
mobile: sanitizeMobile(mobile),
|
||||
mode: "reset_password",
|
||||
});
|
||||
setCooldown(Math.min(response.expires_in_seconds, 120));
|
||||
await api.requestPasswordReset(email);
|
||||
toast({
|
||||
title: "کد بازیابی ارسال شد",
|
||||
description: response.message,
|
||||
variant: "success",
|
||||
title: 'اگر ایمیلی ثبت شده باشد، لینک بازیابی ارسال شد',
|
||||
description: 'ایمیل خود را بررسی کنید.',
|
||||
variant: 'success'
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// بکاند 200 میدهد حتی اگر ایمیل نباشد؛ اما اگر اروری بیاید، نشان بده
|
||||
toast({
|
||||
title: "ارسال کد انجام نشد",
|
||||
description: resolveErrorMessage(error, "امکان ارسال پیامک بازیابی وجود ندارد."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (newPassword.length < 8) {
|
||||
toast({
|
||||
title: "رمز عبور کوتاه است",
|
||||
description: "رمز جدید باید حداقل ۸ کاراکتر داشته باشد.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast({
|
||||
title: "عدم تطابق رمزها",
|
||||
description: "تکرار رمز عبور با رمز جدید یکسان نیست.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.resetPassword({
|
||||
mobile: sanitizeMobile(mobile),
|
||||
code: normalizeDigits(code),
|
||||
new_password: newPassword,
|
||||
});
|
||||
toast({
|
||||
title: "رمز عبور تغییر کرد",
|
||||
description: "اکنون میتوانید با رمز جدید وارد شوید.",
|
||||
variant: "success",
|
||||
});
|
||||
setCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "بازیابی ناموفق بود",
|
||||
description: resolveErrorMessage(error, "کد تأیید یا رمز جدید قابل پذیرش نیست."),
|
||||
variant: "destructive",
|
||||
title: 'خطا',
|
||||
description: resolveErrorMessage(error, 'مشکلی رخ داد'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -105,131 +37,24 @@ export default function ResetPasswordRequest() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-10" dir="rtl">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 lg:flex-row">
|
||||
<Card className="border-border/70 bg-background/85 shadow-xl backdrop-blur-xl lg:w-[22rem]">
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>بازیابی حساب بدون ایمیل</CardTitle>
|
||||
<CardDescription className="leading-7">
|
||||
بازیابی رمز عبور اکنون با موبایل و کد پیامکی انجام میشود. اگر به موبایل ثبتشده هم دسترسی ندارید، از همان حساب گوگلی که قبلاً با ایمیلتان استفاده میکردید کمک بگیرید.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-right">
|
||||
<div className="rounded-[1.5rem] border border-amber-400/30 bg-amber-500/10 p-4 text-sm leading-7 text-amber-900 dark:text-amber-100">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">اگر رمز را فراموش کردهاید</p>
|
||||
<p className="mt-2">
|
||||
میتوانید از مسیر ورود با گوگل ادامه دهید؛ به شرطی که حساب گوگل شما با ایمیل قدیمیتان یکسان باشد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" dir="rtl">
|
||||
<CardHeader>
|
||||
<CardTitle>بازیابی رمز عبور</CardTitle>
|
||||
<CardDescription>ایمیلتان را وارد کنید تا لینک بازیابی برای شما ارسال شود</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">ایمیل</Label>
|
||||
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 w-full rounded-2xl"
|
||||
onClick={() => void api.startGoogleLogin()}
|
||||
>
|
||||
ادامه با حساب گوگل
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'در حال ارسال...' : 'ارسال لینک بازیابی'}
|
||||
</Button>
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-muted/20 p-4 text-sm leading-7 text-muted-foreground">
|
||||
<p className="font-medium text-foreground">گامهای بازیابی</p>
|
||||
<ol className="mt-2 space-y-2">
|
||||
<li>۱. موبایل ثبتشده را وارد کنید.</li>
|
||||
<li>۲. کد پیامکی را دریافت و ثبت کنید.</li>
|
||||
<li>۳. رمز عبور جدید را تعیین کنید.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex-1 border-border/70 bg-background/90 shadow-xl backdrop-blur-xl">
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>تغییر رمز عبور با کد پیامکی</CardTitle>
|
||||
<CardDescription>
|
||||
این فرم جایگزین کامل بازیابی ایمیلی است.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleResetPassword} className="space-y-5">
|
||||
<div>
|
||||
<Label htmlFor="mobile" className="mb-2 block text-right">شماره موبایل</Label>
|
||||
<Input
|
||||
id="mobile"
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
value={mobile}
|
||||
onChange={(event) => setMobile(sanitizeMobile(event.target.value))}
|
||||
placeholder="09xxxxxxxxx"
|
||||
className="h-12 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block text-right">کد بازیابی</Label>
|
||||
<OtpCodeField value={code} onChange={(value) => setCode(normalizeDigits(value))} disabled={loading} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="new-password" className="mb-2 block text-right">رمز عبور جدید</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
className="h-12 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="confirm-password" className="mb-2 block text-right">تکرار رمز عبور</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
className="h-12 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row-reverse">
|
||||
<Button type="submit" className="h-12 flex-1 rounded-2xl" disabled={loading || code.length !== 5}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال ثبت...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="ml-2 h-4 w-4" />
|
||||
ثبت رمز جدید
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-2xl"
|
||||
onClick={() => void handleSendOtp()}
|
||||
disabled={loading || cooldown > 0}
|
||||
>
|
||||
{cooldown > 0 ? `ارسال مجدد تا ${cooldown} ثانیه` : "ارسال کد بازیابی"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<Link to="/auth" className="underline underline-offset-4">
|
||||
بازگشت به صفحه ورود
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowLeft, Loader2, MailWarning } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, CheckCircle2, Info, XCircle } from "lucide-react";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
| { kind: "success"; message: string }
|
||||
| { kind: "already"; message: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["legacy-email-guidance", token],
|
||||
queryFn: () => api.getLegacyVerifyEmailMessage(token || ""),
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["verify-email", token],
|
||||
queryFn: async (): Promise<State> => {
|
||||
if (!token) throw new Error("توکن تأیید یافت نشد.");
|
||||
try {
|
||||
const res = await api.verifyEmail(token);
|
||||
return { kind: "success", message: "ایمیل شما با موفقیت تأیید شد." };
|
||||
} catch (error: unknown) {
|
||||
const msg: string = resolveErrorMessage(error, "").toLowerCase();
|
||||
if (msg.includes("already verified")) {
|
||||
return { kind: "already", message: "ایمیل شما قبلاً تأیید شده است." };
|
||||
}
|
||||
if (msg.includes("invalid verification token")) {
|
||||
return { kind: "error", message: "توکن تأیید نامعتبر است." };
|
||||
}
|
||||
return {
|
||||
kind: "error",
|
||||
message: "متأسفانه خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||
};
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const message = isError
|
||||
? resolveErrorMessage(error, "تأیید ایمیل دیگر برای ورود و بازیابی حساب استفاده نمیشود.")
|
||||
: data?.message ||
|
||||
"تأیید ایمیل غیرفعال شده است. برای ادامه باید موبایل خود را تأیید کنید یا با همان حساب گوگل مرتبط وارد شوید.";
|
||||
useEffect(() => {
|
||||
document.title = "تأیید ایمیل";
|
||||
}, []);
|
||||
|
||||
const renderBody = () => {
|
||||
if (query.isLoading || query.data?.kind === "loading") {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>در حال تأیید ایمیل...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError || query.data?.kind === "error") {
|
||||
const message =
|
||||
(query.data && "message" in query.data && query.data.message) ||
|
||||
"خطای ناشناخته رخ داد";
|
||||
return (
|
||||
<Alert variant="destructive" dir="rtl" className="text-right">
|
||||
<XCircle className="h-5 w-5" />
|
||||
<AlertTitle>خطا</AlertTitle>
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.data?.kind === "already") {
|
||||
return (
|
||||
<Alert dir="rtl" className="text-right">
|
||||
<Info className="h-5 w-5" />
|
||||
<AlertTitle>توجه</AlertTitle>
|
||||
<AlertDescription>{query.data.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// success
|
||||
return (
|
||||
<Alert dir="rtl" className="text-right">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<AlertTitle>تبریک!</AlertTitle>
|
||||
<AlertDescription>ایمیل شما با موفقیت تأیید شد.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-10" dir="rtl">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Card className="border-border/70 bg-background/90 shadow-xl">
|
||||
<CardHeader className="text-right">
|
||||
<div className="mb-4 inline-flex rounded-2xl border border-amber-400/30 bg-amber-500/10 p-3 text-amber-700 dark:text-amber-100">
|
||||
<MailWarning className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle>تأیید ایمیل کنار گذاشته شده است</CardTitle>
|
||||
<CardDescription className="leading-7">
|
||||
دسترسی کاربران به ایمیل محدود شده و مسیر تأیید حساب به موبایل و گوگل منتقل شده است.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 text-right">
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-muted/25 p-4 text-sm leading-7 text-muted-foreground">
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-end gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
در حال دریافت پیام راهنما...
|
||||
</span>
|
||||
) : (
|
||||
message
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-amber-400/30 bg-amber-500/10 p-4 text-sm leading-7 text-amber-900 dark:text-amber-100">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">برای کاربران قدیمی</p>
|
||||
<p className="mt-2">
|
||||
اگر حساب شما قبلاً با ایمیل ساخته شده است، از صفحه ورود گزینه گوگل را بزنید تا در صورت تطابق ایمیل، حسابتان به موبایل متصل شود.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button asChild className="h-12 rounded-2xl">
|
||||
<Link to="/auth">ورود و اتصال حساب</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="h-12 rounded-2xl">
|
||||
<Link to="/reset-password">
|
||||
<ArrowLeft className="ml-2 h-4 w-4" />
|
||||
بازیابی با موبایل
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="min-h-[70vh] flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-lg" dir="rtl">
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>تأیید ایمیل</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">{renderBody()}</CardContent>
|
||||
<CardFooter className="flex items-center justify-between gap-3">
|
||||
<Button asChild variant="secondary" className="min-w-32">
|
||||
<Link to="/">رفتن به صفحهٔ اصلی</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild className="min-w-32">
|
||||
<Link to="/auth">ورود به حساب</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="min-w-32">
|
||||
<Link to="/profile">پروفایل</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default {
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-vazirmatn)", "Tahoma", "Arial", "sans-serif"],
|
||||
sans: ['Vazirmatn', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
|
||||
Reference in New Issue
Block a user