Compare commits
11 Commits
bced5dceb1
...
053b742f89
| Author | SHA1 | Date | |
|---|---|---|---|
| 053b742f89 | |||
| 489e46dd06 | |||
| 25cbf53179 | |||
| cb8eeadba9 | |||
| eb28a00abd | |||
| 1e302d2aa2 | |||
| 4611c8d63b | |||
| 971b709169 | |||
| 492bfd9918 | |||
| 5fcc370611 | |||
| 9051e32e5a |
239
package-lock.json
generated
239
package-lock.json
generated
@@ -8,7 +8,14 @@
|
|||||||
"name": "guilan-ace-frontend",
|
"name": "guilan-ace-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
@@ -106,6 +113,147 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
@@ -914,6 +1062,79 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.18",
|
"version": "15.5.18",
|
||||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@next/env/-/env-15.5.18.tgz",
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@next/env/-/env-15.5.18.tgz",
|
||||||
@@ -3494,6 +3715,12 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -7135,6 +7362,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz",
|
||||||
@@ -7696,6 +7929,12 @@
|
|||||||
"d3-timer": "^3.0.1"
|
"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": {
|
"node_modules/web-namespaces": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,14 @@
|
|||||||
"start": "next start --port 3000"
|
"start": "next start --port 3000"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
|||||||
5
src/app/admin/authorizations/page.tsx
Normal file
5
src/app/admin/authorizations/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminAuthorizations from "@/views/AdminAuthorizations";
|
||||||
|
|
||||||
|
export default function AdminAuthorizationsPage() {
|
||||||
|
return <AdminAuthorizations />;
|
||||||
|
}
|
||||||
5
src/app/admin/blog/categories/page.tsx
Normal file
5
src/app/admin/blog/categories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminBlogCategories from "@/views/AdminBlogCategories";
|
||||||
|
|
||||||
|
export default function AdminBlogCategoriesPage() {
|
||||||
|
return <AdminBlogCategories />;
|
||||||
|
}
|
||||||
5
src/app/admin/blog/tags/page.tsx
Normal file
5
src/app/admin/blog/tags/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminBlogTags from "@/views/AdminBlogTags";
|
||||||
|
|
||||||
|
export default function AdminBlogTagsPage() {
|
||||||
|
return <AdminBlogTags />;
|
||||||
|
}
|
||||||
683
src/components/MarkdownEditor.tsx
Normal file
683
src/components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { LayoutDashboard, LogOut, PencilLine, RotateCcw, UserRound } from "lucide-react";
|
import { LayoutDashboard, LogOut, RotateCcw, UserRound } from "lucide-react";
|
||||||
import { Link, NavLink } from "@/lib/router";
|
import { Link, NavLink } from "@/lib/router";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import ModeToggle from "@/components/ModeToggle";
|
import ModeToggle from "@/components/ModeToggle";
|
||||||
@@ -89,12 +89,6 @@ function ProfileAvatarMenu() {
|
|||||||
<UserRound className="h-4 w-4" />
|
<UserRound className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
|
||||||
<Link to="/profile?edit=1">
|
|
||||||
ویرایش پروفایل
|
|
||||||
<PencilLine className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||||
<Link to="/reset-password">
|
<Link to="/reset-password">
|
||||||
تغییر یا بازیابی رمز
|
تغییر یا بازیابی رمز
|
||||||
|
|||||||
109
src/lib/api.ts
109
src/lib/api.ts
@@ -397,6 +397,21 @@ class ApiClient {
|
|||||||
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
|
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============= Blog Endpoints =============
|
// ============= Blog Endpoints =============
|
||||||
|
|
||||||
async getPosts(params?: {
|
async getPosts(params?: {
|
||||||
@@ -547,6 +562,52 @@ class ApiClient {
|
|||||||
return response.json() as Promise<Types.PostAssetSchema>;
|
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) {
|
async deleteBlogPostAsset(postId: number, assetId: number) {
|
||||||
return this.request<Types.MessageSchema>(`/api/blog/admin/posts/${postId}/assets/${assetId}`, {
|
return this.request<Types.MessageSchema>(`/api/blog/admin/posts/${postId}/assets/${assetId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -648,6 +709,30 @@ class ApiClient {
|
|||||||
return this.request<Types.CategorySchema[]>('/api/blog/categories');
|
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) {
|
async getCategory(slug: string) {
|
||||||
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
|
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
|
||||||
}
|
}
|
||||||
@@ -667,6 +752,30 @@ class ApiClient {
|
|||||||
return this.request<Types.TagSchema[]>('/api/blog/tags');
|
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) {
|
async getTag(slug: string) {
|
||||||
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
|
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,33 @@ export interface UserListSchema {
|
|||||||
date_joined: string;
|
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 {
|
export interface UserRegistrationSchema {
|
||||||
mobile: string;
|
mobile: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -282,6 +309,7 @@ export interface PostListSchema {
|
|||||||
export interface PostDetailSchema extends PostListSchema {
|
export interface PostDetailSchema extends PostListSchema {
|
||||||
content: string;
|
content: string;
|
||||||
content_html?: string;
|
content_html?: string;
|
||||||
|
review_note?: string;
|
||||||
og_image_url?: string | null;
|
og_image_url?: string | null;
|
||||||
views_count?: number;
|
views_count?: number;
|
||||||
assets?: PostAssetSchema[];
|
assets?: PostAssetSchema[];
|
||||||
@@ -403,6 +431,17 @@ export interface CategorySchema {
|
|||||||
created_at: string;
|
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 {
|
export interface TagSchema {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -410,6 +449,15 @@ export interface TagSchema {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminTagSchema extends TagSchema {
|
||||||
|
post_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagWriteSchema {
|
||||||
|
name: string;
|
||||||
|
slug?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BlogFilterCategory {
|
export interface BlogFilterCategory {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
228
src/views/AdminAuthorizations.tsx
Normal file
228
src/views/AdminAuthorizations.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { 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;
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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="flex gap-2">
|
||||||
|
<Button type="button" onClick={() => setSearch(searchDraft.trim())} size="icon" aria-label="جستجو">
|
||||||
|
<Search className="h-5 w-5 bold" />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
value={searchDraft}
|
||||||
|
onChange={(event) => setSearchDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") setSearch(searchDraft.trim());
|
||||||
|
}}
|
||||||
|
placeholder="جستجو..."
|
||||||
|
className="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>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{item.mobile || item.email || item.username}</p>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,14 +3,18 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { BookOpenText, CheckCircle2, Clock3, Edit, Eye, Loader2, Plus, Send, XCircle } from "lucide-react";
|
import { BookOpenText, CheckCircle2, Clock3, Edit, Eye, Loader2, Plus, Send, XCircle } from "lucide-react";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
|
import BlogThumbnail from "@/components/BlogThumbnail";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type * as Types from "@/lib/types";
|
import type * as Types from "@/lib/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
import { formatJalali, resolveErrorMessage } from "@/lib/utils";
|
import { formatJalali, resolveErrorMessage } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -30,6 +34,8 @@ export default function AdminBlog() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actingId, setActingId] = useState<number | null>(null);
|
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 canReview = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
||||||
|
|
||||||
@@ -77,10 +83,10 @@ export default function AdminBlog() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const reviewPost = async (postId: number, action: Types.PostReviewSchema["action"]) => {
|
const reviewPost = async (postId: number, action: Types.PostReviewSchema["action"], note?: string) => {
|
||||||
setActingId(postId);
|
setActingId(postId);
|
||||||
try {
|
try {
|
||||||
await api.reviewBlogPost(postId, { action });
|
await api.reviewBlogPost(postId, { action, note });
|
||||||
await loadPosts();
|
await loadPosts();
|
||||||
toast({ title: action === "publish" ? "نوشته منتشر شد" : "درخواست اصلاح ثبت شد", variant: "success" });
|
toast({ title: action === "publish" ? "نوشته منتشر شد" : "درخواست اصلاح ثبت شد", variant: "success" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -90,38 +96,59 @@ export default function AdminBlog() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-6" dir="rtl">
|
<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="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<Button asChild>
|
|
||||||
<Link to="/admin/blog/new/edit">
|
|
||||||
<Plus className="ml-2 h-4 w-4" />
|
|
||||||
نوشته جدید
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<h2 className="text-2xl font-bold">مدیریت بلاگ</h2>
|
<h2 className="text-2xl font-bold">مدیریت بلاگ</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
پیشنویسها، صف بررسی، انتشار و اصلاح نوشتهها.
|
پیشنویسها، صف بررسی، انتشار و اصلاح نوشتهها.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/admin/blog/new/edit">
|
||||||
|
<Plus className="ml-2 h-4 w-4" />
|
||||||
|
نوشته جدید
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<Card><CardContent className="flex items-center justify-between 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"><BookOpenText className="h-5 w-5 text-primary" /><span>کل: {posts.length}</span></CardContent></Card>
|
||||||
<Card><CardContent className="flex items-center justify-between 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"><Clock3 className="h-5 w-5 text-amber-600" /><span>بررسی: {stats.submitted ?? 0}</span></CardContent></Card>
|
||||||
<Card><CardContent className="flex items-center justify-between 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"><CheckCircle2 className="h-5 w-5 text-emerald-600" /><span>منتشر: {stats.published ?? 0}</span></CardContent></Card>
|
||||||
<Card><CardContent className="flex items-center justify-between p-4"><XCircle className="h-5 w-5 text-rose-600" /><span>اصلاح: {stats.changes_requested ?? 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>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex gap-2">
|
<div className="text-right">
|
||||||
<Button variant="outline" onClick={loadPosts}>جستجو</Button>
|
<CardTitle>نوشتهها</CardTitle>
|
||||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو..." className="w-64 text-right" />
|
<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}>
|
<Select value={status} onValueChange={setStatus}>
|
||||||
<SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-full sm:w-48"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">همه وضعیتها</SelectItem>
|
<SelectItem value="all">همه وضعیتها</SelectItem>
|
||||||
<SelectItem value="draft">پیشنویس</SelectItem>
|
<SelectItem value="draft">پیشنویس</SelectItem>
|
||||||
@@ -132,10 +159,6 @@ export default function AdminBlog() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
|
||||||
<CardTitle>نوشتهها</CardTitle>
|
|
||||||
<CardDescription>دسترسی نویسندهها به نوشتههای خودشان محدود میشود.</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
@@ -143,35 +166,117 @@ export default function AdminBlog() {
|
|||||||
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
||||||
) : posts.length ? (
|
) : posts.length ? (
|
||||||
posts.map((post) => (
|
posts.map((post) => (
|
||||||
<div key={post.id} className="flex flex-col gap-3 rounded-2xl border p-4 md:flex-row md:items-center md:justify-between">
|
<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 flex-wrap gap-2">
|
<div className="flex min-w-0 flex-1 items-start gap-3 md:gap-4">
|
||||||
<Button variant="outline" size="sm" asChild>
|
<BlogThumbnail
|
||||||
<Link to={`/admin/blog/${post.id}/preview`}><Eye className="ml-2 h-4 w-4" />پیشنمایش</Link>
|
post={post}
|
||||||
</Button>
|
imageUrl={post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image}
|
||||||
<Button variant="secondary" size="sm" asChild>
|
className="h-20 w-24 shrink-0 rounded-xl sm:h-24 sm:w-36 md:h-28 md:w-44"
|
||||||
<Link to={`/admin/blog/${post.id}/edit`}><Edit className="ml-2 h-4 w-4" />ویرایش</Link>
|
imageClassName="group-hover:scale-100"
|
||||||
</Button>
|
/>
|
||||||
|
<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" ? (
|
{post.status === "draft" || post.status === "changes_requested" ? (
|
||||||
<Button size="sm" onClick={() => submitPost(post.id)} disabled={actingId === post.id}>
|
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-1 md:block md:basis-auto">
|
||||||
<Send className="ml-2 h-4 w-4" />ارسال برای بررسی
|
<Tooltip>
|
||||||
</Button>
|
<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}
|
) : null}
|
||||||
|
|
||||||
{canReview && post.status === "submitted" ? (
|
{canReview && post.status === "submitted" ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" onClick={() => reviewPost(post.id, "publish")} disabled={actingId === post.id}>انتشار</Button>
|
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-1 md:block md:basis-auto">
|
||||||
<Button size="sm" variant="outline" onClick={() => reviewPost(post.id, "request_changes")} disabled={actingId === post.id}>درخواست اصلاح</Button>
|
<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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
||||||
<Badge variant={post.status === "published" ? "default" : "secondary"}>{statusLabels[post.status] ?? post.status}</Badge>
|
|
||||||
<h3 className="font-semibold">{post.title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{post.updated_at ? formatJalali(post.updated_at, false) : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -181,6 +286,45 @@ export default function AdminBlog() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,95 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowRight, Copy, Loader2, Trash2, UploadCloud } from "lucide-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 { useRouter } from "next/navigation";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } 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 { api } from "@/lib/api";
|
||||||
import type * as Types from "@/lib/types";
|
import type * as Types from "@/lib/types";
|
||||||
import { resolveErrorMessage } from "@/lib/utils";
|
import { cn, resolveErrorMessage } from "@/lib/utils";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
postId: number;
|
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) {
|
export default function AdminBlogAssets({ postId }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const processingRef = useRef(false);
|
||||||
const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
|
const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
|
||||||
const [assets, setAssets] = useState<Types.PostAssetSchema[]>([]);
|
const [assets, setAssets] = useState<Types.PostAssetSchema[]>([]);
|
||||||
|
const [queue, setQueue] = useState<QueueItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
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 () => {
|
const loadData = async () => {
|
||||||
if (!Number.isFinite(postId)) {
|
if (!Number.isFinite(postId)) {
|
||||||
@@ -55,27 +121,71 @@ export default function AdminBlogAssets({ postId }: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
const uploadAsset = async (file: File) => {
|
useEffect(() => {
|
||||||
setUploading(true);
|
const nextItem = queue.find((item) => item.status === "queued");
|
||||||
try {
|
if (!nextItem || processingRef.current) return;
|
||||||
const asset = await api.uploadBlogPostAsset(postId, file, { title: file.name });
|
|
||||||
setAssets((prev) => [asset, ...prev]);
|
processingRef.current = true;
|
||||||
toast({ title: "فایل آپلود شد", variant: "success" });
|
setQueue((current) =>
|
||||||
} catch (error) {
|
current.map((item) => (item.id === nextItem.id ? { ...item, status: "uploading", progress: 1 } : item)),
|
||||||
toast({
|
);
|
||||||
title: "آپلود ناموفق بود",
|
|
||||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
api.uploadBlogPostAssetWithProgress(
|
||||||
variant: "destructive",
|
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]);
|
||||||
});
|
});
|
||||||
} finally {
|
}, [postId, queue]);
|
||||||
setUploading(false);
|
|
||||||
}
|
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>) => {
|
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
if (event.target.files) addFilesToQueue(event.target.files);
|
||||||
event.currentTarget.value = "";
|
event.currentTarget.value = "";
|
||||||
if (file) void uploadAsset(file);
|
};
|
||||||
|
|
||||||
|
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 copySnippet = async (asset: Types.PostAssetSchema) => {
|
||||||
@@ -101,6 +211,15 @@ export default function AdminBlogAssets({ postId }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] items-center justify-center" dir="rtl">
|
<div className="flex min-h-[50vh] items-center justify-center" dir="rtl">
|
||||||
@@ -110,8 +229,8 @@ export default function AdminBlogAssets({ postId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" dir="rtl">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 md:flex-row-reverse md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<h2 className="text-2xl font-bold">مرکز آپلود نوشته</h2>
|
<h2 className="text-2xl font-bold">مرکز آپلود نوشته</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
@@ -124,56 +243,129 @@ export default function AdminBlogAssets({ postId }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="text-right">
|
<CardContent className="space-y-5 p-4 md:p-6">
|
||||||
<CardTitle>آپلود فایل</CardTitle>
|
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onFileChange} />
|
||||||
<CardDescription>تصاویر، ویدئوها، اسناد و فایلهای فشرده مجاز هستند. لینک مارکداون هر فایل بعد از آپلود قابل کپی است.</CardDescription>
|
<button
|
||||||
</CardHeader>
|
type="button"
|
||||||
<CardContent className="space-y-5">
|
onClick={() => fileInputRef.current?.click()}
|
||||||
<input ref={fileInputRef} type="file" className="hidden" onChange={onFileChange} />
|
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"
|
||||||
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
>
|
||||||
{uploading ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <UploadCloud className="ml-2 h-4 w-4" />}
|
<UploadCloud className="mb-3 h-12 w-12 text-muted-foreground" />
|
||||||
آپلود فایل
|
<span className="font-semibold">افزودن فایلهای بیشتر</span>
|
||||||
</Button>
|
<span className="mt-1 text-sm text-muted-foreground">چند فایل را همزمان انتخاب کنید؛ فایلها یکییکی آپلود میشوند.</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{assets.length ? (
|
{queue.length ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="space-y-3">
|
||||||
{assets.map((asset) => (
|
<div className="flex flex-col gap-2 text-right text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div key={asset.id} className="rounded-2xl border p-3">
|
<Button variant="outline" size="sm" onClick={clearFinishedQueue} disabled={hasActiveUpload}>
|
||||||
<div className="flex flex-row-reverse items-start justify-between gap-3">
|
پاکسازی فایلهای تمامشده
|
||||||
<div className="text-right">
|
</Button>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<span>
|
||||||
<Badge variant="secondary">{asset.file_type}</Badge>
|
{queueSummary.total} فایل · {queueSummary.uploaded} موفق · {queueSummary.failed} ناموفق
|
||||||
<p className="font-medium">{asset.title}</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
{queue.map((item) => {
|
||||||
{asset.mime_type || "file"} · {Math.ceil(asset.size / 1024)} KB
|
const kind = fileKindFromFile(item.file);
|
||||||
</p>
|
const Icon = iconForKind(kind);
|
||||||
</div>
|
return (
|
||||||
<div className="flex shrink-0 gap-1">
|
<div key={item.id} className="rounded-2xl border bg-background p-3">
|
||||||
<Button variant="ghost" size="icon" onClick={() => copySnippet(asset)}>
|
<div className="flex items-center gap-3">
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-destructive hover:text-destructive"
|
className="h-8 w-8 shrink-0 text-muted-foreground"
|
||||||
onClick={() => deleteAsset(asset.id)}
|
disabled={item.status === "uploading"}
|
||||||
disabled={deletingId === asset.id}
|
onClick={() => removeQueueItem(item.id)}
|
||||||
>
|
>
|
||||||
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</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}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => deleteAsset(asset.id)}
|
||||||
|
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>
|
||||||
{asset.absolute_preview_url ? (
|
);
|
||||||
<img src={asset.absolute_preview_url} alt={asset.alt_text || asset.title} className="mt-3 aspect-video w-full rounded-xl object-cover" />
|
})}
|
||||||
) : asset.absolute_file_url ? (
|
|
||||||
<a className="mt-3 block truncate rounded-xl bg-muted px-3 py-2 text-left text-xs underline" href={asset.absolute_file_url} target="_blank" rel="noreferrer">
|
|
||||||
{asset.absolute_file_url}
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
<div className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||||
@@ -182,6 +374,25 @@ export default function AdminBlogAssets({ postId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
294
src/views/AdminBlogCategories.tsx
Normal file
294
src/views/AdminBlogCategories.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
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) => {
|
||||||
|
if (!window.confirm(`دستهبندی «${category.name}» حذف شود؟`)) return;
|
||||||
|
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 ? (
|
||||||
|
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteCategory(category)}>
|
||||||
|
<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,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Markdown from "@/components/Markdown";
|
import Markdown from "@/components/Markdown";
|
||||||
|
import MarkdownEditor, { type MarkdownDirectionMode } from "@/components/MarkdownEditor";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -12,6 +14,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type * as Types from "@/lib/types";
|
import type * as Types from "@/lib/types";
|
||||||
@@ -53,11 +56,13 @@ export default function AdminBlogEditor({ postId }: Props) {
|
|||||||
const [loading, setLoading] = useState(Boolean(postId));
|
const [loading, setLoading] = useState(Boolean(postId));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploadingFeatured, setUploadingFeatured] = useState(false);
|
const [uploadingFeatured, setUploadingFeatured] = useState(false);
|
||||||
|
const [editorDirection, setEditorDirection] = useState<MarkdownDirectionMode>("auto");
|
||||||
|
|
||||||
const isNew = postId == null;
|
const isNew = postId == null;
|
||||||
const featuredImage = post?.absolute_featured_image_preview_url || post?.absolute_featured_image_url || post?.featured_image;
|
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 canPersistPost = form.title.trim() && form.content.trim();
|
||||||
const canAssignWriters = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([api.getCategories(), api.getTags()])
|
Promise.all([api.getCategories(), api.getTags()])
|
||||||
@@ -169,13 +174,6 @@ export default function AdminBlogEditor({ postId }: Props) {
|
|||||||
return savePost();
|
return savePost();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openUploadCenter = async () => {
|
|
||||||
const targetPost = await ensureSavedPost();
|
|
||||||
if (targetPost) {
|
|
||||||
router.push(`/admin/blog/${targetPost.id}/assets`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFeaturedImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const onFeaturedImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.currentTarget.value = "";
|
event.currentTarget.value = "";
|
||||||
@@ -227,27 +225,54 @@ export default function AdminBlogEditor({ postId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" dir="rtl">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 md:flex-row-reverse md:items-center md:justify-between">
|
<div className="rounded-3xl border bg-background/90 p-4 shadow-sm">
|
||||||
<div className="text-right">
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
|
<div className="text-right">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
متن را با مارکداون بنویسید، تصویر شاخص را تنظیم کنید و فایلهای داخل متن را از مرکز آپلود جداگانه مدیریت کنید.
|
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
|
||||||
</p>
|
<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>
|
||||||
<Button variant="outline" onClick={() => router.push("/admin/blog")}>
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
بازگشت
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
{reviewNote ? (
|
||||||
<Card>
|
<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">
|
||||||
<CardHeader className="text-right">
|
<div className="flex items-start gap-3 text-right">
|
||||||
<CardTitle>محتوا و سئو</CardTitle>
|
<AlertTriangle className="mt-1 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
<CardDescription>عنوان، متن مارکداون و متادیتای موتورهای جستوجو.</CardDescription>
|
<div className="space-y-2">
|
||||||
</CardHeader>
|
<p className="font-bold">این نوشته نیازمند اصلاح است</p>
|
||||||
<CardContent className="space-y-5">
|
<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 className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-right">عنوان</Label>
|
<Label className="mb-2 block text-right">عنوان</Label>
|
||||||
@@ -275,46 +300,33 @@ export default function AdminBlogEditor({ postId }: Props) {
|
|||||||
<Textarea value={form.excerpt ?? ""} onChange={(event) => updateForm("excerpt", event.target.value)} className="min-h-20 text-right" />
|
<Textarea value={form.excerpt ?? ""} onChange={(event) => updateForm("excerpt", event.target.value)} className="min-h-20 text-right" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="mb-2 block text-right">متن مارکداون</Label>
|
|
||||||
<Textarea
|
|
||||||
value={form.content}
|
|
||||||
onChange={(event) => updateForm("content", event.target.value)}
|
|
||||||
className="min-h-[420px] font-mono text-left"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-right">SEO Title</Label>
|
<Label className="mb-2 block text-right">وضعیت</Label>
|
||||||
<Input value={form.seo_title ?? ""} onChange={(event) => updateForm("seo_title", event.target.value)} className="text-right" maxLength={70} />
|
<Select
|
||||||
</div>
|
value={form.status || "draft"}
|
||||||
<div>
|
onValueChange={(value) => updateForm("status", value as Types.PostCreateSchema["status"])}
|
||||||
<Label className="mb-2 block text-right">Focus Keyword</Label>
|
disabled={!canAssignWriters}
|
||||||
<Input value={form.focus_keyword ?? ""} onChange={(event) => updateForm("focus_keyword", event.target.value)} className="text-right" />
|
>
|
||||||
</div>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">پیشنویس</SelectItem>
|
||||||
<div>
|
<SelectItem value="submitted">در انتظار بررسی</SelectItem>
|
||||||
<Label className="mb-2 block text-right">SEO Description</Label>
|
<SelectItem value="changes_requested">نیازمند تغییر</SelectItem>
|
||||||
<Textarea value={form.seo_description ?? ""} onChange={(event) => updateForm("seo_description", event.target.value)} className="min-h-20 text-right" maxLength={170} />
|
<SelectItem value="published">منتشر شده</SelectItem>
|
||||||
</div>
|
<SelectItem value="archived">آرشیو</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
</Select>
|
||||||
<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>
|
||||||
<div className="flex items-center justify-end gap-2 pt-8">
|
<div className="flex items-center justify-end gap-2 pt-8">
|
||||||
<Label>noindex</Label>
|
<Label>نوشته ویژه</Label>
|
||||||
<Checkbox checked={Boolean(form.noindex)} onCheckedChange={(checked) => updateForm("noindex", Boolean(checked))} />
|
<Checkbox checked={Boolean(form.is_featured)} onCheckedChange={(checked) => updateForm("is_featured", Boolean(checked))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-right">برچسبها</Label>
|
<Label className="mb-2 block text-right">برچسبها</Label>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
const selected = selectedTagIds.includes(tag.id);
|
const selected = selectedTagIds.includes(tag.id);
|
||||||
return (
|
return (
|
||||||
@@ -323,12 +335,7 @@ export default function AdminBlogEditor({ postId }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selected ? "default" : "outline"}
|
variant={selected ? "default" : "outline"}
|
||||||
onClick={() => {
|
onClick={() => updateForm("tag_ids", selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id])}
|
||||||
updateForm(
|
|
||||||
"tag_ids",
|
|
||||||
selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -340,7 +347,7 @@ export default function AdminBlogEditor({ postId }: Props) {
|
|||||||
{canAssignWriters ? (
|
{canAssignWriters ? (
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-right">نویسندگان</Label>
|
<Label className="mb-2 block text-right">نویسندگان</Label>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{users.map((writer) => {
|
{users.map((writer) => {
|
||||||
const selected = selectedWriterIds.includes(writer.id);
|
const selected = selectedWriterIds.includes(writer.id);
|
||||||
const fullName = [writer.first_name, writer.last_name].filter(Boolean).join(" ") || writer.username;
|
const fullName = [writer.first_name, writer.last_name].filter(Boolean).join(" ") || writer.username;
|
||||||
@@ -350,12 +357,7 @@ export default function AdminBlogEditor({ postId }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selected ? "default" : "outline"}
|
variant={selected ? "default" : "outline"}
|
||||||
onClick={() => {
|
onClick={() => updateForm("writer_ids", selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id])}
|
||||||
updateForm(
|
|
||||||
"writer_ids",
|
|
||||||
selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{fullName}
|
{fullName}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -367,84 +369,139 @@ export default function AdminBlogEditor({ postId }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<Card>
|
<input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} />
|
||||||
<CardHeader className="text-right">
|
<div className="overflow-hidden rounded-2xl border bg-muted">
|
||||||
<CardTitle>تصویر شاخص</CardTitle>
|
{featuredImage ? (
|
||||||
<CardDescription>این تصویر به عنوان تامبنیل کارتهای لیست بلاگ و کاور نوشته استفاده میشود.</CardDescription>
|
<img src={featuredImage} alt={post?.title || form.title || "thumbnail"} className="aspect-video w-full object-cover" />
|
||||||
</CardHeader>
|
) : (
|
||||||
<CardContent className="space-y-4">
|
<div className="flex aspect-video items-center justify-center text-sm text-muted-foreground">
|
||||||
<input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} />
|
تصویری انتخاب نشده است.
|
||||||
<div className="overflow-hidden rounded-2xl border bg-muted">
|
</div>
|
||||||
{featuredImage ? (
|
)}
|
||||||
<img src={featuredImage} alt={post?.title || form.title || "thumbnail"} className="aspect-video w-full object-cover" />
|
</div>
|
||||||
) : (
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<div className="flex aspect-video items-center justify-center text-sm text-muted-foreground">
|
{post?.featured_image || post?.absolute_featured_image_url ? (
|
||||||
تصویری انتخاب نشده است.
|
<Button variant="outline" onClick={deleteFeaturedImage} disabled={uploadingFeatured}>
|
||||||
</div>
|
<Trash2 className="ml-2 h-4 w-4" />
|
||||||
)}
|
حذف تصویر
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
|
||||||
{post?.featured_image || post?.absolute_featured_image_url ? (
|
|
||||||
<Button variant="outline" onClick={deleteFeaturedImage} 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>
|
</Button>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="text-right">
|
|
||||||
<CardTitle>مرکز آپلود</CardTitle>
|
|
||||||
<CardDescription>فایلهای داخل متن، تصاویر، اسناد و آرشیوها در صفحه جداگانه همین نوشته مدیریت میشوند.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<Button className="w-full justify-center rounded-2xl py-6" variant="outline" onClick={openUploadCenter} disabled={saving}>
|
|
||||||
<FolderUp className="ml-2 h-4 w-4" />
|
|
||||||
رفتن به مرکز آپلود
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{!post?.id ? (
|
|
||||||
<p className="text-right text-xs text-muted-foreground">
|
|
||||||
برای نوشته جدید، ابتدا پیشنویس ذخیره میشود و سپس مرکز آپلود باز خواهد شد.
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
<Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
|
||||||
</Card>
|
{uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}
|
||||||
|
انتخاب تصویر شاخص
|
||||||
<Card>
|
</Button>
|
||||||
<CardHeader className="text-right">
|
</div>
|
||||||
<CardTitle>پیشنمایش</CardTitle>
|
{post?.id ? (
|
||||||
<CardDescription>همان متن مارکداون بدون ویرایش WYSIWYG.</CardDescription>
|
<Button asChild className="w-full justify-center rounded-2xl py-6" variant="outline">
|
||||||
</CardHeader>
|
<Link to={`/admin/blog/${post.id}/assets`}>
|
||||||
<CardContent>
|
<FolderUp className="ml-2 h-4 w-4" />
|
||||||
<div className="rounded-2xl border bg-background p-4">
|
رفتن به مرکز آپلود
|
||||||
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify size="base" />
|
<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>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="sticky bottom-4 z-20 flex flex-wrap justify-end gap-3 rounded-2xl border bg-background/90 p-3 shadow-lg backdrop-blur">
|
<Card>
|
||||||
<Button variant="secondary" onClick={savePost} disabled={saving}>
|
<CardHeader className="text-right">
|
||||||
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Save className="ml-2 h-4 w-4" />}
|
<CardTitle>متن نوشته</CardTitle>
|
||||||
ذخیره پیشنویس
|
<CardDescription>ویرایشگر مارکداون در کنار پیشنمایش زنده</CardDescription>
|
||||||
</Button>
|
</CardHeader>
|
||||||
<Button onClick={submitForReview} disabled={saving || !canPersistPost}>
|
<CardContent>
|
||||||
<Send className="ml-2 h-4 w-4" />
|
<div className="hidden grid-cols-2 gap-0 overflow-hidden bg-muted/20 md:grid">
|
||||||
ارسال برای بررسی
|
<div className="bg-background ">
|
||||||
</Button>
|
{/* <div className="border-b px-4 py-3 text-right text-sm font-medium">متن مارکداون</div> */}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
244
src/views/AdminBlogTags.tsx
Normal file
244
src/views/AdminBlogTags.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
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) => {
|
||||||
|
if (!window.confirm(`برچسب «${tag.name}» حذف شود؟`)) return;
|
||||||
|
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 ? (
|
||||||
|
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteTag(tag)}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
}, [eventsQuery.data, filters.sort]);
|
}, [eventsQuery.data, filters.sort]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" dir="rtl">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-xl font-semibold">رویدادها</h2>
|
<h2 className="text-xl font-semibold">رویدادها</h2>
|
||||||
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>
|
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>
|
||||||
|
|||||||
@@ -1,24 +1,53 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
FileText,
|
||||||
|
FolderTree,
|
||||||
|
PanelRightClose,
|
||||||
|
PanelRightOpen,
|
||||||
|
ShieldCheck,
|
||||||
|
Tags,
|
||||||
|
UsersRound,
|
||||||
|
} from "lucide-react";
|
||||||
import { Navigate, NavLink, useLocation } from "@/lib/router";
|
import { Navigate, NavLink, useLocation } from "@/lib/router";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/admin/users", label: "مدیریت کاربران", requiresStaff: true },
|
{ to: "/admin/users", label: "کاربران", icon: UsersRound, visibility: "staff" },
|
||||||
{ to: "/admin/events", label: "مدیریت رویدادها", requiresStaff: true },
|
{ to: "/admin/events", label: "رویدادها", icon: CalendarDays, visibility: "staff" },
|
||||||
{ to: "/admin/blog", label: "مدیریت بلاگ", requiresStaff: false },
|
{ 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" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, isAuthenticated, loading } = useAuth();
|
const { user, isAuthenticated, loading } = useAuth();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const canAccessAdmin = useMemo(
|
const canAccessAdmin = useMemo(
|
||||||
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
|
() => 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],
|
[isAuthenticated, user?.can_access_blog_admin, user?.is_staff, user?.is_superuser],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = window.localStorage.getItem("admin-sidebar-collapsed");
|
||||||
|
if (saved) setCollapsed(saved === "true");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
setCollapsed((current) => {
|
||||||
|
const next = !current;
|
||||||
|
window.localStorage.setItem("admin-sidebar-collapsed", String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
|
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
|
||||||
@@ -32,39 +61,107 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const visibleNavItems = navItems.filter((item) => {
|
const visibleNavItems = navItems.filter((item) => {
|
||||||
if (item.requiresStaff) {
|
if (item.visibility === "staff") return Boolean(user?.is_staff || user?.is_superuser);
|
||||||
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);
|
return Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isItemActive = (to: string) => {
|
||||||
|
if (location.pathname === to) return true;
|
||||||
|
if (to === "/admin/blog") {
|
||||||
|
return /^\/admin\/blog\/(new|\d+)/.test(location.pathname ?? "");
|
||||||
|
}
|
||||||
|
return Boolean(location.pathname?.startsWith(`${to}/`));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background" dir="rtl">
|
<div className="min-h-screen bg-muted/15" dir="rtl">
|
||||||
<div className="border-b bg-muted/20">
|
<div className="flex min-h-screen">
|
||||||
<div className="container mx-auto flex items-center justify-between px-4 py-4 gap-4 flex-row-reverse md:flex-row">
|
<aside
|
||||||
<h1 className="text-2xl font-bold">پنل مدیریت</h1>
|
className={cn(
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 ease-in-out lg:flex lg:flex-col",
|
||||||
{visibleNavItems.map((item) => (
|
collapsed ? "w-20" : "w-72",
|
||||||
<NavLink
|
)}
|
||||||
key={item.to}
|
>
|
||||||
to={item.to}
|
<div className="flex items-center justify-between gap-2 border-b p-4">
|
||||||
className={({ isActive }) =>
|
{!collapsed ? (
|
||||||
[
|
<div className="text-right">
|
||||||
"rounded-full px-4 py-2 text-sm transition",
|
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||||
(isActive || location.pathname?.startsWith(item.to))
|
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button variant="ghost" size="icon" onClick={toggleCollapsed} aria-label="باز و بسته کردن منوی مدیریت">
|
||||||
|
{collapsed ? <PanelRightOpen className="h-5 w-5" /> : <PanelRightClose className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 space-y-2 p-3">
|
||||||
|
{visibleNavItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isItemActive(item.to);
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition",
|
||||||
|
collapsed ? "justify-center" : "justify-start",
|
||||||
|
active
|
||||||
? "bg-primary text-primary-foreground shadow"
|
? "bg-primary text-primary-foreground shadow"
|
||||||
: "bg-card text-muted-foreground hover:text-foreground border",
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
].join(" ")
|
)}
|
||||||
}
|
>
|
||||||
>
|
<Icon className="h-5 w-5 shrink-0" />
|
||||||
{item.label}
|
{!collapsed ? <span className="font-medium">{item.label}</span> : null}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="border-b bg-background/90 lg:hidden">
|
||||||
|
<div className="px-4 py-3 text-right">
|
||||||
|
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="container mx-auto min-w-0 px-3 pb-28 pt-4 sm:px-4 lg:py-6">
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="container mx-auto px-4 py-6">
|
<div
|
||||||
{children}
|
className="fixed inset-x-0 z-50 px-4 lg:hidden"
|
||||||
|
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
aria-label="Admin 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"
|
||||||
|
>
|
||||||
|
{visibleNavItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isItemActive(item.to);
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[10px] 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 className="max-w-full truncate">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const AdminUsersPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" dir="rtl">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">کاربران</h2>
|
<h2 className="text-xl font-semibold">کاربران</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">مدیریت و جستجوی کاربران سامانه</p>
|
<p className="text-sm text-muted-foreground mt-1">مدیریت و جستجوی کاربران سامانه</p>
|
||||||
|
|||||||
@@ -545,6 +545,12 @@ export default function Profile() {
|
|||||||
<>
|
<>
|
||||||
<Card className="overflow-hidden rounded-[2rem] border border-border/70 shadow-lg">
|
<Card className="overflow-hidden rounded-[2rem] border border-border/70 shadow-lg">
|
||||||
<CardContent className="space-y-6 p-6 text-center">
|
<CardContent className="space-y-6 p-6 text-center">
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<Button variant="outline" size="sm" className="rounded-full" onClick={() => setEditing(true)}>
|
||||||
|
<PencilLine className="ml-2 h-4 w-4" />
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="flex justify-center">{renderAvatarControl()}</div>
|
<div className="flex justify-center">{renderAvatarControl()}</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{fullName}</h1>
|
<h1 className="text-2xl font-bold">{fullName}</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user