From 192de35ae4a349a65c565d5265b7786c1ddae053 Mon Sep 17 00:00:00 2001 From: Medicopter117 Date: Fri, 15 May 2026 21:11:06 +0200 Subject: [PATCH] refactor: major architectural modularization of CMS and Dashboard API - Refactored monolithic CMSPostEditor.tsx (~1270 lines) into modular sub-components (SEOPanel, EditorToolbar, SidebarPanel) - Modularized backend CMS API routes into a dedicated package structure (posts, media, tags, roadmap, etc.) - Restructured main Dashboard API router into thematic modules (admin, guild, and public routes) - Optimized Vite build configuration by implementing manual chunks for large libraries (Shiki, Recharts, Markdown) - Extracted shared API utilities and added structural documentation - Improved overall code maintainability and scalability across the project --- package-lock.json | 716 ++++++++++++++++- package.json | 8 + src/api/dashboard/admin_routes.py | 109 +++ src/api/dashboard/cms/__init__.py | 19 + src/api/dashboard/cms/feedback.py | 74 ++ src/api/dashboard/cms/media.py | 148 ++++ src/api/dashboard/cms/posts.py | 181 +++++ src/api/dashboard/cms/roadmap.py | 61 ++ src/api/dashboard/cms/tags.py | 57 ++ src/api/dashboard/cms/team.py | 87 +++ src/api/dashboard/cms/utils.py | 84 ++ src/api/dashboard/cms_routes.py | 737 ------------------ src/api/dashboard/guild_routes.py | 196 +++++ src/api/dashboard/public_routes.py | 75 ++ src/api/dashboard/routes.py | 721 +---------------- src/web/components/core/PremiumMarkdown.tsx | 130 +++ src/web/dashboard/cms/CMSPostEditor.tsx | 589 ++++++-------- src/web/dashboard/cms/CMSStatusIndicator.tsx | 73 ++ .../dashboard/cms/editor/EditorToolbar.tsx | 185 +++++ src/web/dashboard/cms/editor/README.md | 18 + src/web/dashboard/cms/editor/SEOPanel.tsx | 271 +++++++ src/web/dashboard/cms/editor/SidebarPanel.tsx | 134 ++++ src/web/index.css | 50 ++ src/web/pages/BlogPage.tsx | 18 +- vite.config.ts | 17 +- 25 files changed, 2942 insertions(+), 1816 deletions(-) create mode 100644 src/api/dashboard/admin_routes.py create mode 100644 src/api/dashboard/cms/__init__.py create mode 100644 src/api/dashboard/cms/feedback.py create mode 100644 src/api/dashboard/cms/media.py create mode 100644 src/api/dashboard/cms/posts.py create mode 100644 src/api/dashboard/cms/roadmap.py create mode 100644 src/api/dashboard/cms/tags.py create mode 100644 src/api/dashboard/cms/team.py create mode 100644 src/api/dashboard/cms/utils.py delete mode 100644 src/api/dashboard/cms_routes.py create mode 100644 src/api/dashboard/guild_routes.py create mode 100644 src/api/dashboard/public_routes.py create mode 100644 src/web/components/core/PremiumMarkdown.tsx create mode 100644 src/web/dashboard/cms/CMSStatusIndicator.tsx create mode 100644 src/web/dashboard/cms/editor/EditorToolbar.tsx create mode 100644 src/web/dashboard/cms/editor/README.md create mode 100644 src/web/dashboard/cms/editor/SEOPanel.tsx create mode 100644 src/web/dashboard/cms/editor/SidebarPanel.tsx diff --git a/package-lock.json b/package-lock.json index e3dbcdd..a317b48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,15 @@ "react-resizable-panels": "4.7.2", "react-router-dom": "7.13.1", "recharts": "^3.8.1", + "rehype-autolink-headings": "^7.1.0", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "remark-toc": "^9.0.0", + "shiki": "^4.0.2", "sonner": "2.0.7", "tailwind-merge": "3.5.0", "tailwindcss-animate": "1.0.7", @@ -3365,6 +3373,106 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4162,6 +4270,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -4207,6 +4321,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ungap__structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/ungap__structured-clone/-/ungap__structured-clone-1.2.0.tgz", + "integrity": "sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4917,6 +5037,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5318,7 +5447,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -5798,6 +5926,12 @@ "node": ">=6" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5831,6 +5965,186 @@ "dev": true, "license": "ISC" }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-heading-rank": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", + "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -5858,6 +6172,54 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -5871,6 +6233,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -5888,6 +6267,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -5911,6 +6299,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6210,6 +6608,22 @@ "node": ">=6" } }, + "node_modules/katex": { + "version": "0.16.46", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.46.tgz", + "integrity": "sha512-WHy4Coo+bGZyH7NwJKHkS04YFsFcarWbAEOAC3EMndzdN6VSZqklLLIgfxzyaW9jDoeGYJX9SWbJPKpecox0Uw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6533,6 +6947,21 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6725,6 +7154,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -6854,6 +7302,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-toc": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-toc/-/mdast-util-toc-7.1.0.tgz", + "integrity": "sha512-2TVKotOQzqdY7THOdn2gGzS9d1Sdd66bvxUyw3aNpWfcPXCLYSJCCgfPy30sEtuzkDraJgqF35dzgmz6xlvH/w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/ungap__structured-clone": "^1.0.0", + "@ungap/structured-clone": "^1.0.0", + "github-slugger": "^2.0.0", + "mdast-util-to-string": "^4.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -7051,6 +7518,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -7525,6 +8011,23 @@ ], "license": "MIT" }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8045,6 +8548,116 @@ "redux": "^5.0.0" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype-autolink-headings": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz", + "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", + "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "github-slugger": "^2.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -8063,6 +8676,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -8111,6 +8740,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-toc": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/remark-toc/-/remark-toc-9.0.0.tgz", + "integrity": "sha512-KJ9txbo33GjDAV1baHFze7ij4G8c7SGYoY8Kzsm2gzFpbhL/bSoVpMMzGa3vrNDSWASNd/3ppAqL7cP2zD6JIA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-toc": "^7.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8239,6 +8882,25 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -8604,6 +9266,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -8630,6 +9306,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -8799,6 +9489,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -9001,6 +9705,16 @@ "node": ">=18" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index 42aa642..f2a22c4 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,15 @@ "react-resizable-panels": "4.7.2", "react-router-dom": "7.13.1", "recharts": "^3.8.1", + "rehype-autolink-headings": "^7.1.0", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "remark-toc": "^9.0.0", + "shiki": "^4.0.2", "sonner": "2.0.7", "tailwind-merge": "3.5.0", "tailwindcss-animate": "1.0.7", diff --git a/src/api/dashboard/admin_routes.py b/src/api/dashboard/admin_routes.py new file mode 100644 index 0000000..12d0d59 --- /dev/null +++ b/src/api/dashboard/admin_routes.py @@ -0,0 +1,109 @@ +from fastapi import APIRouter, Request, HTTPException, Depends +from src.api.dashboard.auth_routes import get_current_user +from .cms.utils import is_admin +from src.bot.core.config import BotConfig +import discord + +router = APIRouter( + prefix="/admin", + tags=["admin"] +) + +# Shared bot instance access (imported from .routes) +def get_bot(): + from .routes import bot_instance + return bot_instance + +@router.get("/global-stats") +async def get_admin_global_stats(user: dict = Depends(get_current_user)): + """Fetches global bot stats and CMS stats for the admin dashboard.""" + bot = get_bot() + if bot is None: + raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") + + # Simple check for admin + is_bot_admin = user.get("id") == "cms_admin" + if not is_bot_admin: + try: + owners = getattr(BotConfig.security, 'bot_owners', []) + if int(user.get("id", 0)) in owners: + is_bot_admin = True + except: pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + try: + from mxmariadb import CMSDatabase + db = CMSDatabase() + await db.ensure_connection() + posts = await db.get_posts(published_only=False) + + return { + "success": True, + "data": { + "totalGuilds": len(bot.guilds), + "totalUsers": len(bot.users), + "totalPosts": len(posts), + "apiLatency": f"{round(bot.latency * 1000)}ms", + "uptime": str(discord.utils.utcnow() - getattr(bot, 'start_time', discord.utils.utcnow())).split('.')[0] + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/blacklist") +async def get_admin_blacklist(user: dict = Depends(get_current_user)): + from mxmariadb import BlacklistDatabase + db = BlacklistDatabase() + await db.ensure_connection() + data = await db.get_all_blacklisted() + return {"success": True, "data": data} + +@router.post("/blacklist") +async def add_admin_blacklist(request: Request, user: dict = Depends(get_current_user)): + data = await request.json() + target_id = data.get("user_id") + reason = data.get("reason", "Kein Grund angegeben") + if not target_id: + raise HTTPException(status_code=400, detail="Target User ID is required") + + from mxmariadb import BlacklistDatabase + db = BlacklistDatabase() + await db.ensure_connection() + success = await db.add_to_blacklist(target_id, reason, user["id"], user.get("username", "Admin")) + return {"success": success} + +@router.delete("/blacklist/{target_id}") +async def remove_admin_blacklist(target_id: str, user: dict = Depends(get_current_user)): + from mxmariadb import BlacklistDatabase + db = BlacklistDatabase() + await db.ensure_connection() + success = await db.remove_from_blacklist(target_id) + return {"success": True} + +@router.get("/global-chat/logs") +async def get_global_chat_logs(user: dict = Depends(get_current_user)): + from mxmariadb import GlobalChatDatabase + db = GlobalChatDatabase() + await db.ensure_connection() + query = "SELECT * FROM message_log ORDER BY timestamp DESC LIMIT 50" + data = await db.fetch_all(query) + return {"success": True, "data": data} + +@router.get("/global-chat/blacklist") +async def get_global_chat_blacklist(user: dict = Depends(get_current_user)): + from mxmariadb import GlobalChatDatabase + db = GlobalChatDatabase() + await db.ensure_connection() + query = "SELECT * FROM globalchat_blacklist ORDER BY banned_at DESC" + data = await db.fetch_all(query) + return {"success": True, "data": data} + +@router.get("/top-commands") +async def get_admin_top_commands(user: dict = Depends(get_current_user)): + from mxmariadb import StatsDB + db = StatsDB() + await db.ensure_connection() + data = await db.get_top_commands(limit=5) + return {"success": True, "data": data} diff --git a/src/api/dashboard/cms/__init__.py b/src/api/dashboard/cms/__init__.py new file mode 100644 index 0000000..20274f7 --- /dev/null +++ b/src/api/dashboard/cms/__init__.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter +from .posts import router as posts_router +from .media import router as media_router +from .tags import router as tags_router +from .roadmap import router as roadmap_router +from .team import router as team_router +from .feedback import router as feedback_router + +router = APIRouter( + prefix="/cms", + tags=["cms"] +) + +router.include_router(posts_router) +router.include_router(media_router) +router.include_router(tags_router) +router.include_router(roadmap_router) +router.include_router(team_router) +router.include_router(feedback_router) diff --git a/src/api/dashboard/cms/feedback.py b/src/api/dashboard/cms/feedback.py new file mode 100644 index 0000000..22c1363 --- /dev/null +++ b/src/api/dashboard/cms/feedback.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Request, HTTPException, Depends +from mxmariadb import CMSDatabase +from .utils import get_cms_db, get_maybe_user, is_admin + +router = APIRouter() + +@router.get("/feedback") +async def get_all_feedback(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Get all feedback items.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + items = await db.get_all_feedback() + return {"success": True, "data": items} + +@router.put("/feedback/{feedback_id}/status") +async def update_feedback_status(feedback_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Update feedback status.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + status = data.get("status") + if status not in ["new", "read", "accepted", "rejected"]: + raise HTTPException(status_code=400, detail="Invalid status") + + success = await db.update_feedback_status(feedback_id, status) + if not success: + raise HTTPException(status_code=500, detail="Failed to update status") + return {"success": True} + +@router.delete("/feedback/{feedback_id}") +async def delete_feedback(feedback_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Delete a feedback item.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + success = await db.delete_feedback(feedback_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete feedback") + return {"success": True} + +@router.post("/feedback/{feedback_id}/to-roadmap") +async def move_feedback_to_roadmap(feedback_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Move a feedback item to the roadmap and mark as accepted.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + feedbacks = await db.get_all_feedback() + item = next((f for f in feedbacks if f["id"] == feedback_id), None) + + if not item: + raise HTTPException(status_code=404, detail="Feedback not found") + + if item["status"] == "accepted": + raise HTTPException(status_code=400, detail="Already moved to roadmap") + + title = f"User Vorschlag ({item['user_name']})" if item["type"] == "suggestion" else f"Bugfix ({item['user_name']})" + icon = "Sparkles" if item["type"] == "suggestion" else "ShieldAlert" + description = item["content"] + + success_roadmap = await db.create_roadmap_item( + title=title, + status="planned", + description=description, + icon=icon, + date_info="Demnächst" + ) + + if not success_roadmap: + raise HTTPException(status_code=500, detail="Failed to create roadmap item") + + await db.update_feedback_status(feedback_id, "accepted") + return {"success": True} diff --git a/src/api/dashboard/cms/media.py b/src/api/dashboard/cms/media.py new file mode 100644 index 0000000..1615f59 --- /dev/null +++ b/src/api/dashboard/cms/media.py @@ -0,0 +1,148 @@ +from fastapi import APIRouter, Request, HTTPException, Depends, UploadFile, File +from fastapi.responses import HTMLResponse +import uuid +import aiofiles +from pathlib import Path +from mxmariadb import CMSDatabase +from .utils import get_cms_db, get_maybe_user, is_admin, get_requester_info, UPLOAD_DIR, ALLOWED_MIME_TYPES, MAX_FILE_SIZE + +router = APIRouter() + +@router.post("/upload") +async def upload_media( + request: Request, + file: UploadFile = File(...), + is_stock: bool = False, + user: dict = Depends(get_maybe_user), + db: CMSDatabase = Depends(get_cms_db) +): + """Admin: upload a media file. Returns the public URL.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + if file.content_type not in ALLOWED_MIME_TYPES: + raise HTTPException(status_code=415, detail=f"Unsupported file type: {file.content_type}") + + content = await file.read() + if len(content) > MAX_FILE_SIZE: + raise HTTPException(status_code=413, detail="File too large (max 10 MB)") + + ext = Path(file.filename).suffix.lower() if file.filename else "" + unique_name = f"{uuid.uuid4().hex}{ext}" + file_path = UPLOAD_DIR / unique_name + + async with aiofiles.open(file_path, "wb") as f: + await f.write(content) + + user_id, username = get_requester_info(request, user) + form_data = await request.form() + stock_flag = form_data.get("is_stock") == "true" or is_stock + + await db.create_media( + filename=unique_name, + original_name=file.filename or unique_name, + mime_type=file.content_type, + size_bytes=len(content), + uploader_id=user_id, + uploader_name=username, + is_stock=stock_flag + ) + + public_url = f"/uploads/cms/{unique_name}" + return {"success": True, "url": public_url, "filename": unique_name, "is_stock": stock_flag} + +@router.get("/media") +async def list_media(request: Request, is_stock: bool = None, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: list uploaded media files, optionally filtered by stock status.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + try: + media = await db.get_media(is_stock=is_stock) + for m in media: + m["url"] = f"/uploads/cms/{m['filename']}" + return {"success": True, "data": media} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/media/{media_id}") +async def update_media_stock(media_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: toggle is_stock flag for media.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + success = await db.update_media(media_id, data.get("is_stock", False)) + if not success: + raise HTTPException(status_code=500, detail="Failed to update media") + return {"success": True} + +@router.get("/media/view/{media_id}", response_class=HTMLResponse) +async def view_media_embed(media_id: int, request: Request, db: CMSDatabase = Depends(get_cms_db)): + """Public: Returns an HTML page with Open Graph tags for Discord embeds.""" + try: + media_list = await db.get_media(limit=1000) + media_item = next((m for m in media_list if m["id"] == media_id), None) + + if not media_item: + return HTMLResponse(content="

Media not found

", status_code=404) + + base_url = str(request.base_url).rstrip('/') + image_url = f"{base_url}/uploads/cms/{media_item['filename']}" + + date_str = "Unknown date" + if media_item.get("uploaded_at"): + date_str = media_item["uploaded_at"].strftime("%d.%m.%Y %H:%M") + + title = media_item["original_name"] + description = f"Hochgeladen am: {date_str} von {media_item.get('uploader_name', 'Unknown')}" + + html_content = f""" + + + + + + {title} - ManagerX Media + + + + + + + + + + + + + {title} +
+

{title}

+

{description}

+
+ + + """ + return HTMLResponse(content=html_content) + except Exception as e: + return HTMLResponse(content=f"

Error

{str(e)}

", status_code=500) + +@router.delete("/media/{media_id}") +async def delete_media(media_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: delete a media file from DB and disk.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + try: + filename = await db.delete_media(media_id) + if filename: + file_path = UPLOAD_DIR / filename + if file_path.exists(): + file_path.unlink() + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/api/dashboard/cms/posts.py b/src/api/dashboard/cms/posts.py new file mode 100644 index 0000000..35ec286 --- /dev/null +++ b/src/api/dashboard/cms/posts.py @@ -0,0 +1,181 @@ +from fastapi import APIRouter, Request, HTTPException, Depends +from mxmariadb import CMSDatabase +from .utils import get_cms_db, get_maybe_user, is_admin, get_requester_info, slugify + +router = APIRouter() + +# ─── PUBLIC ENDPOINTS ───────────────────────────────────────────────────────── + +@router.get("/posts") +async def get_public_posts(post_type: str = None, db: CMSDatabase = Depends(get_cms_db)): + """Get all published posts, optionally filtered by type.""" + try: + posts = await db.get_posts(post_type=post_type, published_only=True) + return {"success": True, "data": posts} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/posts/by-slug/{slug}") +async def get_post_by_slug(slug: str, db: CMSDatabase = Depends(get_cms_db)): + """Get a single published post by slug and increment view count.""" + try: + post = await db.get_post_by_slug(slug) + if not post: + raise HTTPException(status_code=404, detail="Post not found") + await db.increment_view_count(post["id"]) + return {"success": True, "data": post} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +# ─── ADMIN – POSTS ──────────────────────────────────────────────────────────── + +@router.get("/admin/posts") +async def get_admin_posts(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: list ALL posts (drafts + published).""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + try: + posts = await db.get_posts(published_only=False) + return {"success": True, "data": posts} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/posts") +async def create_post(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: create a new post. Automatically saves a revision on creation.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + title = data.get("title") + content = data.get("content") + if not title or not content: + raise HTTPException(status_code=400, detail="Title and content are required") + + post_type = data.get("post_type", "dev") + is_published = data.get("is_published", False) + tags = data.get("tags", "") + slug = data.get("slug") or slugify(title) + scheduled_at = data.get("scheduled_at") + excerpt = data.get("excerpt") + cover_image = data.get("cover_image") + + user_id, username = get_requester_info(request, user) + + try: + await db.create_post( + post_type=post_type, title=title, slug=slug, content=content, + author_id=user_id, author_name=username, tags=tags, + is_published=is_published, scheduled_at=scheduled_at, + excerpt=excerpt, cover_image=cover_image + ) + return {"success": True} + except Exception as e: + if "Duplicate entry" in str(e): + raise HTTPException(status_code=400, detail="Slug already exists") + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/posts/{post_id}") +async def update_post(post_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: update a post and save a revision snapshot.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + user_id, username = get_requester_info(request, user) + + # Save revision BEFORE update (if substantial fields changed) + revision_fields = {"title", "content", "tags", "cover_image", "excerpt"} + if revision_fields & set(data.keys()): + old_post = await db.get_post_by_id(post_id) + if old_post: + try: + await db.save_revision( + post_id=post_id, + title=old_post.get("title", ""), + content=old_post.get("content", ""), + tags=old_post.get("tags", ""), + cover_image=old_post.get("cover_image"), + excerpt=old_post.get("excerpt"), + changed_by_id=user_id, + changed_by_name=username, + change_note=data.get("change_note") + ) + except Exception as rev_err: + print(f"[WARN] Revision save failed: {rev_err}") + + try: + await db.update_post(post_id, **data) + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/posts/{post_id}") +async def delete_post(post_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: delete a post (cascades to revisions).""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + try: + await db.delete_post(post_id) + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +# ─── ADMIN – REVISIONS ──────────────────────────────────────────────────────── + +@router.get("/posts/{post_id}/revisions") +async def get_revisions(post_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: get revision history for a post.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + try: + revisions = await db.get_revisions(post_id) + return {"success": True, "data": revisions} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/revisions/{revision_id}") +async def get_single_revision(revision_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: get full content of a specific revision.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + rev = await db.get_revision_by_id(revision_id) + if not rev: + raise HTTPException(status_code=404, detail="Revision not found") + return {"success": True, "data": rev} + +@router.post("/posts/{post_id}/restore/{revision_id}") +async def restore_revision(post_id: int, revision_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: restore a post to a specific revision.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + rev = await db.get_revision_by_id(revision_id) + if not rev or rev["post_id"] != post_id: + raise HTTPException(status_code=404, detail="Revision not found for this post") + + user_id, username = get_requester_info(request, user) + + # Save current state as a revision first + old = await db.get_post_by_id(post_id) + if old: + await db.save_revision( + post_id=post_id, + title=old["title"], content=old["content"], + tags=old.get("tags", ""), cover_image=old.get("cover_image"), + excerpt=old.get("excerpt"), + changed_by_id=user_id, changed_by_name=username, + change_note=f"Auto-saved before restoring revision #{revision_id}" + ) + + await db.update_post( + post_id, + title=rev["title"], + content=rev["content"], + tags=rev.get("tags", ""), + cover_image=rev.get("cover_image"), + excerpt=rev.get("excerpt") + ) + return {"success": True, "message": f"Restored to revision #{revision_id}"} diff --git a/src/api/dashboard/cms/roadmap.py b/src/api/dashboard/cms/roadmap.py new file mode 100644 index 0000000..8c94f52 --- /dev/null +++ b/src/api/dashboard/cms/roadmap.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Request, HTTPException, Depends +from mxmariadb import CMSDatabase +from .utils import get_cms_db, get_maybe_user, is_admin + +router = APIRouter() + +@router.get("/roadmap") +async def get_roadmap(db: CMSDatabase = Depends(get_cms_db)): + """Public: Get all roadmap items.""" + items = await db.get_roadmap() + return {"success": True, "data": items} + +@router.post("/roadmap") +async def create_roadmap_item(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Create a new roadmap item.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + success = await db.create_roadmap_item( + title=data.get("title"), + status=data.get("status", "planned"), + description=data.get("description"), + icon=data.get("icon", "Rocket"), + date_info=data.get("date_info"), + order_index=data.get("order_index", 0) + ) + if not success: + raise HTTPException(status_code=500, detail="Failed to create roadmap item") + return {"success": True} + +@router.put("/roadmap/{item_id}") +async def update_roadmap_item(item_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Update an existing roadmap item.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + success = await db.update_roadmap_item( + item_id=item_id, + title=data.get("title"), + status=data.get("status"), + description=data.get("description"), + icon=data.get("icon"), + date_info=data.get("date_info"), + order_index=data.get("order_index", 0) + ) + if not success: + raise HTTPException(status_code=500, detail="Failed to update roadmap item") + return {"success": True} + +@router.delete("/roadmap/{item_id}") +async def delete_roadmap_item(item_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Delete a roadmap item.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + success = await db.delete_roadmap_item(item_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete roadmap item") + return {"success": True} diff --git a/src/api/dashboard/cms/tags.py b/src/api/dashboard/cms/tags.py new file mode 100644 index 0000000..eee1262 --- /dev/null +++ b/src/api/dashboard/cms/tags.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Request, HTTPException, Depends +from mxmariadb import CMSDatabase +from .utils import get_cms_db, get_maybe_user, is_admin, slugify + +router = APIRouter() + +@router.get("/tags") +async def list_tags(db: CMSDatabase = Depends(get_cms_db)): + """Public/Admin: list all tags.""" + try: + tags = await db.get_tags() + return {"success": True, "data": tags} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/tags") +async def create_tag(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: create a new tag.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + name = data.get("name") + if not name: + raise HTTPException(status_code=400, detail="Name is required") + + slug = data.get("slug") or slugify(name) + color = data.get("color", "#3498db") + emoji = data.get("emoji", "") + + success = await db.create_tag(name=name, slug=slug, color=color, emoji=emoji) + if not success: + raise HTTPException(status_code=500, detail="Failed to create tag") + return {"success": True} + +@router.put("/tags/{tag_id}") +async def update_tag(tag_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: update an existing tag.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + success = await db.update_tag(tag_id, **data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update tag") + return {"success": True} + +@router.delete("/tags/{tag_id}") +async def delete_tag(tag_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: delete a tag.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + success = await db.delete_tag(tag_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete tag") + return {"success": True} diff --git a/src/api/dashboard/cms/team.py b/src/api/dashboard/cms/team.py new file mode 100644 index 0000000..4a1ac6b --- /dev/null +++ b/src/api/dashboard/cms/team.py @@ -0,0 +1,87 @@ +from fastapi import APIRouter, Request, HTTPException, Depends +from mxmariadb import CMSDatabase +from .utils import get_cms_db, get_maybe_user, is_admin + +router = APIRouter() + +@router.get("/team-categories") +async def get_team_categories(db: CMSDatabase = Depends(get_cms_db)): + """Public: Get all team categories.""" + items = await db.get_team_categories() + return {"success": True, "data": items} + +@router.post("/team-categories") +async def create_team_category(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Create a new team category.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + success = await db.create_team_category(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to create category") + return {"success": True} + +@router.put("/team-categories/{cat_id}") +async def update_team_category(cat_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Update a team category.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + success = await db.update_team_category(cat_id, data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update category") + return {"success": True} + +@router.delete("/team-categories/{cat_id}") +async def delete_team_category(cat_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Delete a team category.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + success = await db.delete_team_category(cat_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete category") + return {"success": True} + +@router.get("/team") +async def get_team(db: CMSDatabase = Depends(get_cms_db)): + """Public: Get all team members.""" + items = await db.get_team() + return {"success": True, "data": items} + +@router.post("/team") +async def create_team_member(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Create a new team member.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + success = await db.create_team_member(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to create team member") + return {"success": True} + +@router.put("/team/{member_id}") +async def update_team_member(member_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Update an existing team member.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + success = await db.update_team_member(member_id, data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update team member") + return {"success": True} + +@router.delete("/team/{member_id}") +async def delete_team_member(member_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: Delete a team member.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + success = await db.delete_team_member(member_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete team member") + return {"success": True} diff --git a/src/api/dashboard/cms/utils.py b/src/api/dashboard/cms/utils.py new file mode 100644 index 0000000..e133887 --- /dev/null +++ b/src/api/dashboard/cms/utils.py @@ -0,0 +1,84 @@ +from fastapi import Request, HTTPException, Depends +from src.api.dashboard.auth_routes import get_current_user +from mxmariadb import CMSDatabase +from src.bot.core.config import BotConfig +import re +from pathlib import Path + +# Upload-Verzeichnis +UPLOAD_DIR = Path(__file__).resolve().parents[4] / "public" / "uploads" / "cms" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +ALLOWED_MIME_TYPES = { + "image/jpeg", "image/png", "image/gif", "image/webp", + "image/svg+xml", "video/mp4", "application/pdf" +} +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB + +def slugify(text: str) -> str: + """Simple slugify function.""" + text = text.lower() + text = re.sub(r'[^\w\s-]', '', text) + text = re.sub(r'[-\s]+', '-', text) + return text.strip('-') + +async def get_maybe_user(request: Request): + """Optional JWT user – returns None if unauthenticated.""" + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return None + try: + return get_current_user(request) + except Exception: + return None + +def is_admin(request: Request, user: dict = None) -> bool: + """Check if the requester is a bot owner or CMS admin.""" + bypass_enabled = getattr(BotConfig.api, 'localhost_bypass', False) + client_ip = request.client.host + + if bypass_enabled and client_ip in ["127.0.0.1", "localhost"]: + x_user_id = request.headers.get("X-User-ID") + if x_user_id: + if x_user_id == "cms_admin": + return True + try: + user_id = int(x_user_id) + owners = getattr(BotConfig.security, 'bot_owners', []) + if user_id in owners: + return True + except (ValueError, TypeError): + pass + + if not user: + return False + + uid = user["id"] + if uid == "cms_admin": + return True + + try: + user_id = int(uid) + owners = getattr(BotConfig.security, 'bot_owners', []) + return user_id in owners + except (ValueError, TypeError): + return False + +def get_requester_info(request: Request, user: dict) -> tuple[int, str]: + """Returns (user_id, username) from JWT or fallback header.""" + if user: + try: + return int(user["id"]), user.get("username", "Unknown") + except (ValueError, TypeError): + return 0, user.get("username", "Unknown") + + x_user_id = request.headers.get("X-User-ID") + try: + return int(x_user_id) if x_user_id else 0, "Admin" + except (ValueError, TypeError): + return 0, "Admin" + +async def get_cms_db() -> CMSDatabase: + db = CMSDatabase() + await db.ensure_connection() + return db diff --git a/src/api/dashboard/cms_routes.py b/src/api/dashboard/cms_routes.py deleted file mode 100644 index 0aa3577..0000000 --- a/src/api/dashboard/cms_routes.py +++ /dev/null @@ -1,737 +0,0 @@ -from fastapi import APIRouter, Request, HTTPException, Depends, UploadFile, File -from fastapi.responses import JSONResponse -from src.api.dashboard.auth_routes import get_current_user -from mxmariadb import CMSDatabase -from src.bot.core.config import BotConfig -import re -import uuid -import os -import aiofiles -from pathlib import Path - -# Upload-Verzeichnis -UPLOAD_DIR = Path(__file__).resolve().parents[3] / "public" / "uploads" / "cms" -UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - -ALLOWED_MIME_TYPES = { - "image/jpeg", "image/png", "image/gif", "image/webp", - "image/svg+xml", "video/mp4", "application/pdf" -} -MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB - -router = APIRouter( - prefix="/cms", - tags=["cms"] -) - -# ───────────────────────────────────────── -# HELPERS -# ───────────────────────────────────────── - -def slugify(text: str) -> str: - """Simple slugify function.""" - text = text.lower() - text = re.sub(r'[^\w\s-]', '', text) - text = re.sub(r'[-\s]+', '-', text) - return text.strip('-') - -async def get_maybe_user(request: Request): - """Optional JWT user – returns None if unauthenticated.""" - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - return None - try: - return get_current_user(request) - except Exception: - return None - -def is_admin(request: Request, user: dict = None) -> bool: - """Check if the requester is a bot owner or CMS admin.""" - bypass_enabled = getattr(BotConfig.api, 'localhost_bypass', False) - client_ip = request.client.host - - if bypass_enabled and client_ip in ["127.0.0.1", "localhost"]: - x_user_id = request.headers.get("X-User-ID") - if x_user_id: - if x_user_id == "cms_admin": - return True - try: - user_id = int(x_user_id) - owners = getattr(BotConfig.security, 'bot_owners', []) - if user_id in owners: - print(f"[DEBUG] CMS Access granted via Localhost Bypass for ID {user_id}") - return True - except (ValueError, TypeError): - pass - - if not user: - return False - - uid = user["id"] - if uid == "cms_admin": - return True - - try: - user_id = int(uid) - owners = getattr(BotConfig.security, 'bot_owners', []) - return user_id in owners - except (ValueError, TypeError): - return False - -def get_requester_info(request: Request, user: dict) -> tuple[int, str]: - """Returns (user_id, username) from JWT or fallback header.""" - if user: - try: - return int(user["id"]), user.get("username", "Unknown") - except (ValueError, TypeError): - # Special case for non-numeric IDs like 'cms_admin' - return 0, user.get("username", "Unknown") - - x_user_id = request.headers.get("X-User-ID") - try: - return int(x_user_id) if x_user_id else 0, "Admin" - except (ValueError, TypeError): - return 0, "Admin" - -async def get_cms_db() -> CMSDatabase: - db = CMSDatabase() - await db.ensure_connection() - return db - -# ───────────────────────────────────────── -# PUBLIC ENDPOINTS -# ───────────────────────────────────────── - -@router.get("/posts") -async def get_public_posts(post_type: str = None, db: CMSDatabase = Depends(get_cms_db)): - """Get all published posts, optionally filtered by type.""" - try: - posts = await db.get_posts(post_type=post_type, published_only=True) - return {"success": True, "data": posts} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/posts/by-slug/{slug}") -async def get_post_by_slug(slug: str, db: CMSDatabase = Depends(get_cms_db)): - """Get a single published post by slug and increment view count.""" - try: - post = await db.get_post_by_slug(slug) - if not post: - raise HTTPException(status_code=404, detail="Post not found") - await db.increment_view_count(post["id"]) - return {"success": True, "data": post} - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/changelog") -async def get_public_changelog(limit: int = 50, db: CMSDatabase = Depends(get_cms_db)): - """Public changelog feed.""" - try: - entries = await db.get_changelog(limit=limit) - return {"success": True, "data": entries} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -# ───────────────────────────────────────── -# ADMIN – POSTS -# ───────────────────────────────────────── - -@router.get("/admin/posts") -async def get_admin_posts(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: list ALL posts (drafts + published).""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - try: - posts = await db.get_posts(published_only=False) - return {"success": True, "data": posts} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/posts") -async def create_post(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: create a new post. Automatically saves a revision on creation.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - title = data.get("title") - content = data.get("content") - if not title or not content: - raise HTTPException(status_code=400, detail="Title and content are required") - - post_type = data.get("post_type", "dev") - is_published = data.get("is_published", False) - tags = data.get("tags", "") - slug = data.get("slug") or slugify(title) - scheduled_at = data.get("scheduled_at") - excerpt = data.get("excerpt") - cover_image = data.get("cover_image") - - user_id, username = get_requester_info(request, user) - - try: - await db.create_post( - post_type=post_type, title=title, slug=slug, content=content, - author_id=user_id, author_name=username, tags=tags, - is_published=is_published, scheduled_at=scheduled_at, - excerpt=excerpt, cover_image=cover_image - ) - return {"success": True} - except Exception as e: - if "Duplicate entry" in str(e): - raise HTTPException(status_code=400, detail="Slug already exists") - raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/posts/{post_id}") -async def update_post(post_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: update a post and save a revision snapshot.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - user_id, username = get_requester_info(request, user) - - # Save revision BEFORE update (if substantial fields changed) - revision_fields = {"title", "content", "tags", "cover_image", "excerpt"} - if revision_fields & set(data.keys()): - old_post = await db.get_post_by_id(post_id) - if old_post: - try: - await db.save_revision( - post_id=post_id, - title=old_post.get("title", ""), - content=old_post.get("content", ""), - tags=old_post.get("tags", ""), - cover_image=old_post.get("cover_image"), - excerpt=old_post.get("excerpt"), - changed_by_id=user_id, - changed_by_name=username, - change_note=data.get("change_note") - ) - except Exception as rev_err: - print(f"[WARN] Revision save failed: {rev_err}") - - try: - await db.update_post(post_id, **data) - return {"success": True} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.delete("/posts/{post_id}") -async def delete_post(post_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: delete a post (cascades to revisions).""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - try: - await db.delete_post(post_id) - return {"success": True} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -# ───────────────────────────────────────── -# ADMIN – REVISIONS -# ───────────────────────────────────────── - -@router.get("/posts/{post_id}/revisions") -async def get_revisions(post_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: get revision history for a post.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - try: - revisions = await db.get_revisions(post_id) - return {"success": True, "data": revisions} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/revisions/{revision_id}") -async def get_single_revision(revision_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: get full content of a specific revision.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - rev = await db.get_revision_by_id(revision_id) - if not rev: - raise HTTPException(status_code=404, detail="Revision not found") - return {"success": True, "data": rev} - -@router.post("/posts/{post_id}/restore/{revision_id}") -async def restore_revision(post_id: int, revision_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: restore a post to a specific revision.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - rev = await db.get_revision_by_id(revision_id) - if not rev or rev["post_id"] != post_id: - raise HTTPException(status_code=404, detail="Revision not found for this post") - - user_id, username = get_requester_info(request, user) - - # Save current state as a revision first - old = await db.get_post_by_id(post_id) - if old: - await db.save_revision( - post_id=post_id, - title=old["title"], content=old["content"], - tags=old.get("tags", ""), cover_image=old.get("cover_image"), - excerpt=old.get("excerpt"), - changed_by_id=user_id, changed_by_name=username, - change_note=f"Auto-saved before restoring revision #{revision_id}" - ) - - await db.update_post( - post_id, - title=rev["title"], - content=rev["content"], - tags=rev.get("tags", ""), - cover_image=rev.get("cover_image"), - excerpt=rev.get("excerpt") - ) - return {"success": True, "message": f"Restored to revision #{revision_id}"} - -# ───────────────────────────────────────── -# ADMIN – MEDIA -# ───────────────────────────────────────── - -@router.post("/upload") -async def upload_media( - request: Request, - file: UploadFile = File(...), - is_stock: bool = False, - user: dict = Depends(get_maybe_user), - db: CMSDatabase = Depends(get_cms_db) -): - """Admin: upload a media file. Returns the public URL.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - # Validate MIME type - if file.content_type not in ALLOWED_MIME_TYPES: - raise HTTPException(status_code=415, detail=f"Unsupported file type: {file.content_type}") - - # Read file (with size check) - content = await file.read() - if len(content) > MAX_FILE_SIZE: - raise HTTPException(status_code=413, detail="File too large (max 10 MB)") - - # Generate unique filename preserving extension - ext = Path(file.filename).suffix.lower() if file.filename else "" - unique_name = f"{uuid.uuid4().hex}{ext}" - file_path = UPLOAD_DIR / unique_name - - async with aiofiles.open(file_path, "wb") as f: - await f.write(content) - - user_id, username = get_requester_info(request, user) - - # Convert string boolean if sent via form-data - form_data = await request.form() - stock_flag = form_data.get("is_stock") == "true" or is_stock - - await db.create_media( - filename=unique_name, - original_name=file.filename or unique_name, - mime_type=file.content_type, - size_bytes=len(content), - uploader_id=user_id, - uploader_name=username, - is_stock=stock_flag - ) - - public_url = f"/uploads/cms/{unique_name}" - return {"success": True, "url": public_url, "filename": unique_name, "is_stock": stock_flag} - -@router.get("/media") -async def list_media(request: Request, is_stock: bool = None, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: list uploaded media files, optionally filtered by stock status.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - try: - media = await db.get_media(is_stock=is_stock) - # Enrich with public URLs - for m in media: - m["url"] = f"/uploads/cms/{m['filename']}" - return {"success": True, "data": media} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/media/{media_id}") -async def update_media_stock(media_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: toggle is_stock flag for media.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - success = await db.update_media(media_id, data.get("is_stock", False)) - if not success: - raise HTTPException(status_code=500, detail="Failed to update media") - return {"success": True} - -# ───────────────────────────────────────── -# PUBLIC MEDIA VIEWER (FOR DISCORD EMBEDS) -# ───────────────────────────────────────── - -from fastapi.responses import HTMLResponse - -@router.get("/media/view/{media_id}", response_class=HTMLResponse) -async def view_media_embed(media_id: int, request: Request, db: CMSDatabase = Depends(get_cms_db)): - """Public: Returns an HTML page with Open Graph tags for Discord embeds.""" - try: - # We need a new method or just fetch all and filter, or add get_media_by_id - media_list = await db.get_media(limit=1000) - media_item = next((m for m in media_list if m["id"] == media_id), None) - - if not media_item: - return HTMLResponse(content="

Media not found

", status_code=404) - - # Build absolute URL for the image - base_url = str(request.base_url).rstrip('/') - image_url = f"{base_url}/uploads/cms/{media_item['filename']}" - - # Format date safely - date_str = "Unknown date" - if media_item.get("uploaded_at"): - date_str = media_item["uploaded_at"].strftime("%d.%m.%Y %H:%M") - - title = media_item["original_name"] - description = f"Hochgeladen am: {date_str} von {media_item.get('uploader_name', 'Unknown')}" - - html_content = f""" - - - - - - {title} - ManagerX Media - - - - - - - - - - - - - - - - - - {title} -
-

{title}

-

{description}

-
- - - """ - return HTMLResponse(content=html_content) - except Exception as e: - return HTMLResponse(content=f"

Error

{str(e)}

", status_code=500) - - -@router.delete("/media/{media_id}") -async def delete_media(media_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: delete a media file from DB and disk.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - try: - filename = await db.delete_media(media_id) - if filename: - file_path = UPLOAD_DIR / filename - if file_path.exists(): - file_path.unlink() - return {"success": True} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -# ───────────────────────────────────────── -# ADMIN – TAGS -# ───────────────────────────────────────── - -@router.get("/tags") -async def list_tags(db: CMSDatabase = Depends(get_cms_db)): - """Public/Admin: list all tags.""" - try: - tags = await db.get_tags() - return {"success": True, "data": tags} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/tags") -async def create_tag(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: create a new tag.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - name = data.get("name") - if not name: - raise HTTPException(status_code=400, detail="Name is required") - - slug = data.get("slug") or slugify(name) - color = data.get("color", "#3498db") - emoji = data.get("emoji", "") - - success = await db.create_tag(name=name, slug=slug, color=color, emoji=emoji) - if not success: - raise HTTPException(status_code=500, detail="Failed to create tag") - return {"success": True} - -@router.put("/tags/{tag_id}") -async def update_tag(tag_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: update an existing tag.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - success = await db.update_tag(tag_id, **data) - if not success: - raise HTTPException(status_code=500, detail="Failed to update tag") - return {"success": True} - -@router.delete("/tags/{tag_id}") -async def delete_tag(tag_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: delete a tag.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - success = await db.delete_tag(tag_id) - if not success: - raise HTTPException(status_code=500, detail="Failed to delete tag") - return {"success": True} - -# ───────────────────────────────────────── -# ROADMAP ROUTES -# ───────────────────────────────────────── - -@router.get("/roadmap") -async def get_roadmap(db: CMSDatabase = Depends(get_cms_db)): - """Public: Get all roadmap items.""" - items = await db.get_roadmap() - return {"success": True, "data": items} - -@router.post("/roadmap") -async def create_roadmap_item(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Create a new roadmap item.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - success = await db.create_roadmap_item( - title=data.get("title"), - status=data.get("status", "planned"), - description=data.get("description"), - icon=data.get("icon", "Rocket"), - date_info=data.get("date_info"), - order_index=data.get("order_index", 0) - ) - if not success: - raise HTTPException(status_code=500, detail="Failed to create roadmap item") - return {"success": True} - -@router.put("/roadmap/{item_id}") -async def update_roadmap_item(item_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Update an existing roadmap item.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - success = await db.update_roadmap_item( - item_id=item_id, - title=data.get("title"), - status=data.get("status"), - description=data.get("description"), - icon=data.get("icon"), - date_info=data.get("date_info"), - order_index=data.get("order_index", 0) - ) - if not success: - raise HTTPException(status_code=500, detail="Failed to update roadmap item") - return {"success": True} - -@router.delete("/roadmap/{item_id}") -async def delete_roadmap_item(item_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Delete a roadmap item.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - success = await db.delete_roadmap_item(item_id) - if not success: - raise HTTPException(status_code=500, detail="Failed to delete roadmap item") - return {"success": True} - -# ───────────────────────────────────────── -# TEAM CATEGORIES ROUTES -# ───────────────────────────────────────── - -@router.get("/team-categories") -async def get_team_categories(db: CMSDatabase = Depends(get_cms_db)): - """Public: Get all team categories.""" - items = await db.get_team_categories() - return {"success": True, "data": items} - -@router.post("/team-categories") -async def create_team_category(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Create a new team category.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - success = await db.create_team_category(data) - if not success: - raise HTTPException(status_code=500, detail="Failed to create category") - return {"success": True} - -@router.put("/team-categories/{cat_id}") -async def update_team_category(cat_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Update a team category.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - success = await db.update_team_category(cat_id, data) - if not success: - raise HTTPException(status_code=500, detail="Failed to update category") - return {"success": True} - -@router.delete("/team-categories/{cat_id}") -async def delete_team_category(cat_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Delete a team category.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - success = await db.delete_team_category(cat_id) - if not success: - raise HTTPException(status_code=500, detail="Failed to delete category") - return {"success": True} - -# ───────────────────────────────────────── -# TEAM ROUTES -# ───────────────────────────────────────── - -@router.get("/team") -async def get_team(db: CMSDatabase = Depends(get_cms_db)): - """Public: Get all team members.""" - items = await db.get_team() - return {"success": True, "data": items} - -@router.post("/team") -async def create_team_member(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Create a new team member.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - success = await db.create_team_member(data) - if not success: - raise HTTPException(status_code=500, detail="Failed to create team member") - return {"success": True} - -@router.put("/team/{member_id}") -async def update_team_member(member_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Update an existing team member.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - success = await db.update_team_member(member_id, data) - if not success: - raise HTTPException(status_code=500, detail="Failed to update team member") - return {"success": True} - -@router.delete("/team/{member_id}") -async def delete_team_member(member_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Delete a team member.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - success = await db.delete_team_member(member_id) - if not success: - raise HTTPException(status_code=500, detail="Failed to delete team member") - return {"success": True} - -# ───────────────────────────────────────── -# FEEDBACK ROUTES -# ───────────────────────────────────────── - -@router.get("/feedback") -async def get_all_feedback(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Get all feedback items.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - items = await db.get_all_feedback() - return {"success": True, "data": items} - -@router.put("/feedback/{feedback_id}/status") -async def update_feedback_status(feedback_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Update feedback status.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - status = data.get("status") - if status not in ["new", "read", "accepted", "rejected"]: - raise HTTPException(status_code=400, detail="Invalid status") - - success = await db.update_feedback_status(feedback_id, status) - if not success: - raise HTTPException(status_code=500, detail="Failed to update status") - return {"success": True} - -@router.delete("/feedback/{feedback_id}") -async def delete_feedback(feedback_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Delete a feedback item.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - success = await db.delete_feedback(feedback_id) - if not success: - raise HTTPException(status_code=500, detail="Failed to delete feedback") - return {"success": True} - -@router.post("/feedback/{feedback_id}/to-roadmap") -async def move_feedback_to_roadmap(feedback_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: Move a feedback item to the roadmap and mark as accepted.""" - if not is_admin(request, user): - raise HTTPException(status_code=403, detail="Not authorized") - - # 1. Feedback abrufen - feedbacks = await db.get_all_feedback() - item = next((f for f in feedbacks if f["id"] == feedback_id), None) - - if not item: - raise HTTPException(status_code=404, detail="Feedback not found") - - if item["status"] == "accepted": - raise HTTPException(status_code=400, detail="Already moved to roadmap") - - # 2. Roadmap Eintrag erstellen - title = f"User Vorschlag ({item['user_name']})" if item["type"] == "suggestion" else f"Bugfix ({item['user_name']})" - icon = "Sparkles" if item["type"] == "suggestion" else "ShieldAlert" - description = item["content"] - - success_roadmap = await db.create_roadmap_item( - title=title, - status="planned", - description=description, - icon=icon, - date_info="Demnächst" - ) - - if not success_roadmap: - raise HTTPException(status_code=500, detail="Failed to create roadmap item") - - # 3. Status auf accepted setzen - await db.update_feedback_status(feedback_id, "accepted") - - return {"success": True} diff --git a/src/api/dashboard/guild_routes.py b/src/api/dashboard/guild_routes.py new file mode 100644 index 0000000..99a2ca0 --- /dev/null +++ b/src/api/dashboard/guild_routes.py @@ -0,0 +1,196 @@ +from fastapi import APIRouter, Request, HTTPException, Depends +from datetime import timedelta +import discord +from src.api.dashboard.auth_routes import get_current_user + +router = APIRouter( + prefix="/guilds", + tags=["guilds"] +) + +def get_bot(): + from .routes import bot_instance + return bot_instance + +async def check_guild_perms(guild_id: int, user_id: int): + bot = get_bot() + if bot is None: + raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") + + guild = bot.get_guild(guild_id) + if not guild: + raise HTTPException(status_code=404, detail="Guild not found or bot not in guild") + + member = guild.get_member(user_id) + if not member: + try: + member = await guild.fetch_member(user_id) + except: + raise HTTPException(status_code=403, detail="Nutzer nicht auf dem Server gefunden") + + if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + return guild, member + +@router.get("/{guild_id}/channels") +async def get_guild_channels(guild_id: int, user: dict = Depends(get_current_user)): + guild, _ = await check_guild_perms(guild_id, int(user["id"])) + channels = [{"id": str(c.id), "name": c.name} for c in guild.text_channels] + return {"channels": channels} + +@router.get("/{guild_id}/roles") +async def get_guild_roles(guild_id: int, user: dict = Depends(get_current_user)): + guild, _ = await check_guild_perms(guild_id, int(user["id"])) + roles = [ + {"id": str(r.id), "name": r.name, "color": str(r.color)} + for r in guild.roles + if not r.is_default() and not r.managed + ] + return {"roles": roles} + +@router.get("/{guild_id}/categories") +async def get_guild_categories(guild_id: int, user: dict = Depends(get_current_user)): + guild, _ = await check_guild_perms(guild_id, int(user["id"])) + categories = [{"id": str(c.id), "name": c.name} for c in guild.categories] + return {"categories": categories} + +@router.get("/{guild_id}/voice_channels") +async def get_guild_voice_channels(guild_id: int, user: dict = Depends(get_current_user)): + guild, _ = await check_guild_perms(guild_id, int(user["id"])) + channels = [{"id": str(c.id), "name": c.name} for c in guild.voice_channels] + return {"channels": channels} + +@router.get("/{guild_id}/stats") +async def get_guild_stats(guild_id: int, user: dict = Depends(get_current_user)): + bot = get_bot() + guild, _ = await check_guild_perms(guild_id, int(user["id"])) + + today_dt = discord.utils.utcnow() + today_str = today_dt.strftime('%Y-%m-%d') + yesterday_str = (today_dt - timedelta(days=1)).strftime('%Y-%m-%d') + joined_today = 0 + joined_yesterday = 0 + messages_today = 0 + messages_yesterday = 0 + history = [] + + try: + welcome_history = [] + stats_history = [] + + if hasattr(bot, 'welcome_db'): + welcome_history = await bot.welcome_db.get_weekly_stats(guild_id) + for day in welcome_history: + if day['date'] == today_str: joined_today = day['joins'] + elif day['date'] == yesterday_str: joined_yesterday = day['joins'] + + if hasattr(bot, 'stats_db'): + messages_today = await bot.stats_db.get_daily_messages(guild_id, today_str) + messages_yesterday = await bot.stats_db.get_daily_messages(guild_id, yesterday_str) + stats_history = await bot.stats_db.get_weekly_stats(guild_id) + + for i in range(6, -1, -1): + date_obj = today_dt - timedelta(days=i) + d_str = date_obj.strftime('%Y-%m-%d') + day_name = date_obj.strftime('%a') + m_count = next((h['messages'] for h in stats_history if h['date'] == d_str), 0) + j_count = next((h['joins'] for h in welcome_history if h['date'] == d_str), 0) + history.append({"name": day_name, "messages": m_count, "joins": j_count}) + + def calc_trend(today, yesterday): + if today == yesterday: return "neutral", "0%" + if yesterday == 0: return "up", "+100%" + diff = today - yesterday + pct = round((abs(diff) / yesterday) * 100) + return ("up" if diff > 0 else "down"), f"{'+' if diff > 0 else '-'}{pct}%" + + m_trend, m_trend_val = calc_trend(messages_today, messages_yesterday) + j_trend, j_trend_val = calc_trend(joined_today, joined_yesterday) + + server_age_str = f"{(today_dt - guild.created_at).days}d" + staff_list = [] + user_list = [] + + if hasattr(bot, 'settings_db'): + gs = bot.settings_db.get_guild_settings(guild_id) + if tid := gs.get("team_role_id"): + if tr := guild.get_role(int(tid)): + staff_list = [{"name": m.display_name, "id": str(m.id), "avatar": m.display_avatar.url} for m in tr.members] + if uid := gs.get("user_role_id"): + if ur := guild.get_role(int(uid)): + user_list = [{"name": m.display_name, "id": str(m.id), "avatar": m.display_avatar.url} for m in ur.members] + + total_members = guild.member_count or len(guild.members) + online_members = sum(1 for m in guild.members if m.status != discord.Status.offline) if guild.members else 0 + + return { + "total_members": total_members, + "online_members": online_members, + "text_channels": len(guild.text_channels), + "voice_channels": len(guild.voice_channels), + "joined_today": joined_today, + "joined_trend": j_trend, + "joined_trend_value": j_trend_val, + "messages_today": messages_today, + "messages_trend": m_trend, + "messages_trend_value": m_trend_val, + "history": history, + "server_age": server_age_str, + "staff_members": staff_list, + "user_members": user_list + } + except Exception as e: + print(f"Stats error: {e}") + return {"total_members": guild.member_count, "online_members": 0} + +@router.get("/{guild_id}/mega-data") +async def get_mega_data(guild_id: int, user: dict = Depends(get_current_user)): + bot = get_bot() + guild, _ = await check_guild_perms(guild_id, int(user["id"])) + + try: + stats = await get_guild_stats(guild_id, user) + from mxmariadb import WelcomeDatabase, AntiSpamDatabase, GlobalChatDatabase, LevelDatabase, LoggingDatabase, ManagementDatabase + + level_active = LevelDatabase().get_guild_config(guild_id).get("enabled", False) if LevelDatabase().get_guild_config(guild_id) else False + antispam_active = bool(AntiSpamDatabase().get_spam_settings(guild_id)) + welcome_active = bool(await WelcomeDatabase().get_welcome_settings(guild_id)) + global_active = bool(GlobalChatDatabase().get_guild_settings(guild_id)) + logging_active = bool(await LoggingDatabase().get_all_log_channels(guild_id)) + + db_m = ManagementDatabase() + await db_m.ensure_connection() + autoresponder_active = len(await db_m.get_auto_responses(guild_id)) > 0 + applications_active = len(await db_m.get_questions(guild_id)) > 0 + newssync_active = any(c['guild_id'] == guild_id for c in await db_m.get_sync_channels()) + + return { + "success": True, + "data": { + "settings": { + "bot_name": bot.user.name, + "prefix": "!", + "auto_mod": antispam_active, + "welcome_message": welcome_active, + "language": "de", + "level_system": level_active, + "anti_spam": antispam_active, + "global_network": global_active, + "logging": logging_active, + "auto_responder": autoresponder_active, + "applications": applications_active, + "news_sync": newssync_active, + "economy": False + }, + "stats": stats, + "metadata": { + "channels": [{"id": str(c.id), "name": c.name} for c in guild.text_channels], + "roles": [{"id": str(r.id), "name": r.name, "color": str(r.color)} for r in guild.roles if not r.is_default() and not r.managed], + "categories": [{"id": str(c.id), "name": c.name} for c in guild.categories], + "voice_channels": [{"id": str(c.id), "name": c.name} for c in guild.voice_channels] + } + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/api/dashboard/public_routes.py b/src/api/dashboard/public_routes.py new file mode 100644 index 0000000..5e4f829 --- /dev/null +++ b/src/api/dashboard/public_routes.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, Request, HTTPException +import discord +from typing import List, Optional + +router = APIRouter( + prefix="/v1/managerx", + tags=["public"] +) + +def get_bot(): + from .routes import bot_instance + return bot_instance + +@router.get("/stats") +async def get_stats(request: Request): + bot = get_bot() + if bot is None: + raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") + + try: + now = discord.utils.utcnow() + start = getattr(bot, 'start_time', now) + if start.tzinfo is None: start = start.replace(tzinfo=discord.utils.timezone.utc) + + uptime_seconds = (now - start).total_seconds() + uptime_minutes, remainder = divmod(int(uptime_seconds), 60) + uptime_hours, uptime_minutes = divmod(uptime_minutes, 60) + uptime_days, uptime_hours = divmod(uptime_hours, 24) + + return { + "uptime": f"{int(uptime_days)}d {int(uptime_hours)}h {int(uptime_minutes)}m", + "latency": f"{round(bot.latency * 1000)}ms", + "guilds": len(bot.guilds), + "users": len(bot.users), + "bot_name": bot.user.name if bot.user else "Unknown", + "bot_id": bot.user.id if bot.user else None, + "status": "online", + "database": "connected" + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/leaderboard") +async def get_leaderboard(limit: int = 50): + from mxmariadb import StatsDB + bot = get_bot() + if bot is None: + raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") + + try: + stats_db = StatsDB() + rows = await stats_db.get_leaderboard(limit=limit) + leaderboard = [] + for row in rows: + uid = row[0] + is_private = row[5] if len(row) > 5 else 0 + if is_private: + username, avatar = "Anonymer Nutzer", None + else: + user = bot.get_user(uid) + username = user.name if user else f"User {uid}" + avatar = user.display_avatar.url if user else None + + leaderboard.append({ + "user_id": str(uid), "username": username, "avatar_url": avatar, + "level": row[1], "xp": row[2], "messages": row[3], + "voice_minutes": round(row[4], 1) + }) + return {"success": True, "leaderboard": leaderboard} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/version") +async def get_version(): + return {"pypi_version": "1.2026.5.7", "bot_version": "v2.1.0-open-beta"} diff --git a/src/api/dashboard/routes.py b/src/api/dashboard/routes.py index 0e43f51..0c4d383 100644 --- a/src/api/dashboard/routes.py +++ b/src/api/dashboard/routes.py @@ -1,735 +1,34 @@ -from fastapi import APIRouter, Request, HTTPException, Security, status, Depends -from fastapi.security import APIKeyHeader -import os -import discord -from src.api.dashboard.auth_routes import get_current_user -from typing import List, Optional -from datetime import datetime, timedelta, timezone -import time -# Falls du Schemas nutzt: from .schemas import ServerStatus, UserInfo - +from fastapi import APIRouter from .auth_routes import router as auth_router from .settings_routes import router as settings_router from .user_routes import router as user_router from .management_routes import router as management_router -from .cms_routes import router as cms_router +from .cms import router as cms_router +from .admin_routes import router as admin_router +from .guild_routes import router as guild_router +from .public_routes import router as public_router # Wir erstellen einen Router, den wir später in die Haupt-App einbinden -router_public = APIRouter( - prefix="/v1/managerx", - tags=["public"] -) +router_public = public_router # Global Bot-Referenz (wird später in main.py gesetzt) bot_instance = None def set_bot_instance(bot): - """ - Setzt die globale Bot-Instanz für die API. - Diese Funktion wird aus main.py aufgerufen. - - Args: - bot: Die discord.py Bot-Instanz - """ + """Setzt die globale Bot-Instanz für die API.""" global bot_instance bot_instance = bot - -@router_public.get("/stats", response_model=dict) -async def get_stats(request: Request): - """ - Endpoint to get the current server status with real bot data. - - Returns: - dict: Server status mit echten Bot-Daten - """ - if bot_instance is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - try: - # Berechne Uptime (Robust gegen Naive/Aware-Mix) - now = discord.utils.utcnow() - start = getattr(bot_instance, 'start_time', now) - - # Sicherstellen, dass beide aware sind - if start.tzinfo is None: - start = start.replace(tzinfo=timezone.utc) - - uptime_seconds = (now - start).total_seconds() - uptime_minutes, remainder = divmod(int(uptime_seconds), 60) - uptime_hours, uptime_minutes = divmod(uptime_minutes, 60) - uptime_days, uptime_hours = divmod(uptime_hours, 24) - - uptime_str = f"{int(uptime_days)}d {int(uptime_hours)}h {int(uptime_minutes)}m" - - # Sammle echte Daten vom Bot - server_status = { - "uptime": uptime_str, - "latency": f"{round(bot_instance.latency * 1000)}ms", - "guilds": len(bot_instance.guilds), - "users": len(bot_instance.users), - "bot_name": bot_instance.user.name if bot_instance.user else "Unknown", - "bot_id": bot_instance.user.id if bot_instance.user else None, - "status": "online" if bot_instance.latency != float('inf') else "offline", - "database": "connected" if hasattr(bot_instance, 'settings_db') and bot_instance.settings_db else "disconnected" - } - return server_status - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router_public.get("/leaderboard") -async def get_leaderboard(limit: int = 50): - """ - Fetches the global leaderboard from StatsDB and enriches it with Discord data. - """ - from mxmariadb import StatsDB - if bot_instance is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - try: - stats_db = StatsDB() - blacklist_db = BlacklistDatabase() - # get_leaderboard returns user_id, global_level, global_xp, total_messages, total_voice_minutes - rows = await stats_db.get_leaderboard(limit=limit) - - leaderboard = [] - for row in rows: - uid = row[0] - is_private = row[5] if len(row) > 5 else 0 - - if is_private: - username = "Anonymer Nutzer" - avatar = None - else: - user = bot_instance.get_user(uid) - username = user.name if user else f"User {uid}" - avatar = user.display_avatar.url if user else None - - leaderboard.append({ - "user_id": str(uid), - "username": username, - "avatar_url": avatar, - "level": row[1], - "xp": row[2], - "messages": row[3], - "voice_minutes": round(row[4], 1) - }) - - return {"success": True, "leaderboard": leaderboard} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router_public.get("/version") -async def get_version(request: Request): - return { - "pypi_version": "1.2026.5.7", - "bot_version": "v2.1.0-open-beta" - } - - -API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False) - -async def get_api_key(api_key_header: str = Security(API_KEY_HEADER)): - """Überprüft den API-Key aus dem Header.""" - allowed_keys = os.getenv("DASHBOARD_API_KEYS") - - if not allowed_keys: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Keine API-Keys konfiguriert (DASHBOARD_API_KEYS fehlt)" - ) - - key_list = [k.strip() for k in allowed_keys.split(",") if k.strip()] - - if not api_key_header or api_key_header not in key_list: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Zugriff verweigert: Ungültiger API-Key" - ) - return api_key_header - dashboard_main_router = APIRouter( prefix="/dashboard", tags=["dashboard"] ) -@dashboard_main_router.get("/admin/global-stats") -async def get_admin_global_stats(user: dict = Depends(get_current_user)): - """Fetches global bot stats and CMS stats for the admin dashboard.""" - # Auth check: Nur cms_admin oder bot owners - from .cms_routes import is_admin - # We need the request object for is_admin, but for now we simplify - # as we already have the user from get_current_user - is_bot_admin = False - if user.get("id") == "cms_admin": - is_bot_admin = True - else: - from src.bot.core.config import BotConfig - owners = getattr(BotConfig.security, 'bot_owners', []) - try: - if int(user.get("id", 0)) in owners: - is_bot_admin = True - except: - pass - - if not is_bot_admin: - raise HTTPException(status_code=403, detail="Not authorized") - - if bot_instance is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - try: - from mxmariadb import CMSDatabase - db = CMSDatabase() - await db.ensure_connection() - posts = await db.get_posts(published_only=False) - - return { - "success": True, - "data": { - "totalGuilds": len(bot_instance.guilds), - "totalUsers": len(bot_instance.users), - "totalPosts": len(posts), - "apiLatency": f"{round(bot_instance.latency * 1000)}ms", - "uptime": str(discord.utils.utcnow() - getattr(bot_instance, 'start_time', discord.utils.utcnow())).split('.')[0] - } - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@dashboard_main_router.get("/admin/blacklist") -async def get_admin_blacklist(user: dict = Depends(get_current_user)): - # Auth check: Nur cms_admin oder bot owners - user_id = user.get("id") - is_bot_admin = False - if user_id == "cms_admin": - is_bot_admin = True - else: - from src.bot.core.config import BotConfig - owners = getattr(BotConfig.security, 'bot_owners', []) - try: - if int(user_id) in owners: - is_bot_admin = True - except: - pass - - if not is_bot_admin: - raise HTTPException(status_code=403, detail="Not authorized") - - from mxmariadb import BlacklistDatabase - db = BlacklistDatabase() - await db.ensure_connection() - data = await db.get_all_blacklisted() - return {"success": True, "data": data} - -@dashboard_main_router.post("/admin/blacklist") -async def add_admin_blacklist(request: Request, user: dict = Depends(get_current_user)): - user_id = user.get("id") - is_bot_admin = False - if user_id == "cms_admin": - is_bot_admin = True - else: - from src.bot.core.config import BotConfig - owners = getattr(BotConfig.security, 'bot_owners', []) - try: - if int(user_id) in owners: - is_bot_admin = True - except: - pass - - if not is_bot_admin: - raise HTTPException(status_code=403, detail="Not authorized") - - data = await request.json() - target_id = data.get("user_id") - reason = data.get("reason", "Kein Grund angegeben") - - if not target_id: - raise HTTPException(status_code=400, detail="Target User ID is required") - - from mxmariadb import BlacklistDatabase - db = BlacklistDatabase() - await db.ensure_connection() - success = await db.add_to_blacklist(target_id, reason, user_id, user.get("username", "Admin")) - return {"success": success} - -@dashboard_main_router.delete("/admin/blacklist/{target_id}") -async def remove_admin_blacklist(target_id: str, user: dict = Depends(get_current_user)): - user_id = user.get("id") - is_bot_admin = False - if user_id == "cms_admin": - is_bot_admin = True - else: - from src.bot.core.config import BotConfig - owners = getattr(BotConfig.security, 'bot_owners', []) - try: - if int(user_id) in owners: - is_bot_admin = True - except: - pass - - if not is_bot_admin: - raise HTTPException(status_code=403, detail="Not authorized") - - from mxmariadb import BlacklistDatabase - db = BlacklistDatabase() - await db.ensure_connection() - success = await db.remove_from_blacklist(target_id) - return {"success": True} - -# --- GLOBAL CHAT CONTROL --- - -@dashboard_main_router.get("/admin/global-chat/logs") -async def get_global_chat_logs(user: dict = Depends(get_current_user)): - user_id = user.get("id") - is_bot_admin = False - if user_id == "cms_admin": - is_bot_admin = True - else: - from src.bot.core.config import BotConfig - owners = getattr(BotConfig.security, 'bot_owners', []) - try: - if int(user_id) in owners: - is_bot_admin = True - except: - pass - - if not is_bot_admin: - raise HTTPException(status_code=403, detail="Not authorized") - - from mxmariadb import GlobalChatDatabase - db = GlobalChatDatabase() - await db.ensure_connection() - # Letzte 50 Nachrichten - query = "SELECT * FROM message_log ORDER BY timestamp DESC LIMIT 50" - data = await db.fetch_all(query) - return {"success": True, "data": data} - -@dashboard_main_router.get("/admin/global-chat/blacklist") -async def get_global_chat_blacklist(user: dict = Depends(get_current_user)): - user_id = user.get("id") - is_bot_admin = False - if user_id == "cms_admin": - is_bot_admin = True - else: - from src.bot.core.config import BotConfig - owners = getattr(BotConfig.security, 'bot_owners', []) - try: - if int(user_id) in owners: - is_bot_admin = True - except: - pass - - if not is_bot_admin: - raise HTTPException(status_code=403, detail="Not authorized") - - from mxmariadb import GlobalChatDatabase - db = GlobalChatDatabase() - await db.ensure_connection() - query = "SELECT * FROM globalchat_blacklist ORDER BY banned_at DESC" - data = await db.fetch_all(query) - return {"success": True, "data": data} - -@dashboard_main_router.get("/admin/top-commands") -async def get_admin_top_commands(user: dict = Depends(get_current_user)): - user_id = user.get("id") - is_bot_admin = False - if user_id == "cms_admin": - is_bot_admin = True - else: - from src.bot.core.config import BotConfig - owners = getattr(BotConfig.security, 'bot_owners', []) - try: - if int(user_id) in owners: - is_bot_admin = True - except: - pass - - if not is_bot_admin: - raise HTTPException(status_code=403, detail="Not authorized") - - from mxmariadb import StatsDB - db = StatsDB() - await db.ensure_connection() - data = await db.get_top_commands(limit=5) - return {"success": True, "data": data} - -# Public sub-routers -@dashboard_main_router.get("/guilds/{guild_id}/channels") -async def get_guild_channels(guild_id: int, user: dict = Depends(get_current_user)): - """Fetches text channels for a specific guild.""" - if bot_instance is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - guild = bot_instance.get_guild(guild_id) - if not guild: - raise HTTPException(status_code=404, detail="Guild not found or bot not in guild") - - # Check if user is in guild and has appropriate permissions (Manage Guild or Admin) - member = guild.get_member(int(user["id"])) - if not member: - try: - member = await guild.fetch_member(int(user["id"])) - except: - raise HTTPException(status_code=403, detail="Nutzer nicht auf dem Server gefunden") - - if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator): - raise HTTPException(status_code=403, detail="Insufficient permissions") - - channels = [ - {"id": str(c.id), "name": c.name} - for c in guild.text_channels - ] - return {"channels": channels} - -@dashboard_main_router.get("/guilds/{guild_id}/roles") -async def get_guild_roles(guild_id: int, user: dict = Depends(get_current_user)): - """Fetches manageable roles for a specific guild.""" - if bot_instance is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - guild = bot_instance.get_guild(guild_id) - if not guild: - raise HTTPException(status_code=404, detail="Guild not found or bot not in guild") - - member = guild.get_member(int(user["id"])) - if not member: - try: - member = await guild.fetch_member(int(user["id"])) - except: - raise HTTPException(status_code=403, detail="Nutzer nicht auf dem Server gefunden") - - if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator): - raise HTTPException(status_code=403, detail="Insufficient permissions") - - roles = [ - {"id": str(r.id), "name": r.name, "color": str(r.color)} - for r in guild.roles - if not r.is_default() and not r.managed - ] - return {"roles": roles} - -@dashboard_main_router.get("/guilds/{guild_id}/categories") -async def get_guild_categories(guild_id: int, user: dict = Depends(get_current_user)): - """Fetches categories for a specific guild.""" - if bot_instance is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - guild = bot_instance.get_guild(guild_id) - if not guild: - raise HTTPException(status_code=404, detail="Guild not found or bot not in guild") - - member = guild.get_member(int(user["id"])) - if not member: - try: - member = await guild.fetch_member(int(user["id"])) - except: - raise HTTPException(status_code=403, detail="Nutzer nicht auf dem Server gefunden") - - if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator): - raise HTTPException(status_code=403, detail="Insufficient permissions") - - categories = [ - {"id": str(c.id), "name": c.name} - for c in guild.categories - ] - return {"categories": categories} - -@dashboard_main_router.get("/guilds/{guild_id}/voice_channels") -async def get_guild_voice_channels(guild_id: int, user: dict = Depends(get_current_user)): - """Fetches voice channels for a specific guild.""" - if bot_instance is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - guild = bot_instance.get_guild(guild_id) - if not guild: - raise HTTPException(status_code=404, detail="Guild not found or bot not in guild") - - member = guild.get_member(int(user["id"])) - if not member: - try: - member = await guild.fetch_member(int(user["id"])) - except: - raise HTTPException(status_code=403, detail="Nutzer nicht auf dem Server gefunden") - - if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator): - raise HTTPException(status_code=403, detail="Insufficient permissions") - - channels = [ - {"id": str(c.id), "name": c.name} - for c in guild.voice_channels - ] - return {"channels": channels} - -@dashboard_main_router.get("/guilds/{guild_id}/stats") -async def get_guild_stats(guild_id: int, user: dict = Depends(get_current_user)): - """Fetches server statistics (Daily joins, message count, member total).""" - if bot_instance is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - guild = bot_instance.get_guild(guild_id) - if not guild: - raise HTTPException(status_code=404, detail="Guild not found or bot not in guild") - - member = guild.get_member(int(user["id"])) - if not member: - try: - member = await guild.fetch_member(int(user["id"])) - except: - raise HTTPException(status_code=403, detail="Nutzer nicht auf dem Server gefunden") - - if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator): - raise HTTPException(status_code=403, detail="Insufficient permissions") - - # Fetch daily growth/activity - today_dt = discord.utils.utcnow() - today_str = today_dt.strftime('%Y-%m-%d') - yesterday_str = (today_dt - timedelta(days=1)).strftime('%Y-%m-%d') - joined_today = 0 - joined_yesterday = 0 - messages_today = 0 - messages_yesterday = 0 - history = [] - - try: - # Pre-fetch histories - welcome_history = [] - stats_history = [] - - if hasattr(bot_instance, 'welcome_db'): - welcome_history = await bot_instance.welcome_db.get_weekly_stats(guild_id) - for day in welcome_history: - if day['date'] == today_str: - joined_today = day['joins'] - elif day['date'] == yesterday_str: - joined_yesterday = day['joins'] - - if hasattr(bot_instance, 'stats_db'): - messages_today = await bot_instance.stats_db.get_daily_messages(guild_id, today_str) - messages_yesterday = await bot_instance.stats_db.get_daily_messages(guild_id, yesterday_str) - stats_history = await bot_instance.stats_db.get_weekly_stats(guild_id) - - # 2. Combine history for the last 7 days - for i in range(6, -1, -1): - date_obj = today_dt - timedelta(days=i) - d_str = date_obj.strftime('%Y-%m-%d') - day_name = date_obj.strftime('%a') - m_count = 0 - j_count = 0 - for h in stats_history: - if h['date'] == d_str: - m_count = h['messages']; break - for h in welcome_history: - if h['date'] == d_str: - j_count = h['joins']; break - history.append({"name": day_name, "messages": m_count, "joins": j_count}) - - # Calculate Trends - def calc_trend(today, yesterday): - if today == yesterday: - return "neutral", "0%" - if yesterday == 0: - return "up", "+100%" - diff = today - yesterday - pct = round((abs(diff) / yesterday) * 100) - return ("up" if diff > 0 else "down"), f"{'+' if diff > 0 else '-'}{pct}%" - - m_trend, m_trend_val = calc_trend(messages_today, messages_yesterday) - j_trend, j_trend_val = calc_trend(joined_today, joined_yesterday) - - # Calculate Server Age (NEU) - server_age_days = (today_dt - guild.created_at).days - server_age_str = f"{server_age_days}d" - - # Fetch Staff / User Role members (NEU) - staff_members_list = [] - user_members_list = [] - - if hasattr(bot_instance, 'settings_db') and hasattr(bot_instance.settings_db, 'get_guild_settings'): - guild_settings = bot_instance.settings_db.get_guild_settings(guild_id) - team_role_id = guild_settings.get("team_role_id") - user_role_id = guild_settings.get("user_role_id") - - if team_role_id: - team_role = guild.get_role(int(team_role_id)) - if team_role: - for m in team_role.members: - staff_members_list.append({"name": m.display_name, "id": str(m.id), "avatar": m.display_avatar.url}) - - if user_role_id: - user_role = guild.get_role(int(user_role_id)) - if user_role: - for m in user_role.members: - user_members_list.append({"name": m.display_name, "id": str(m.id), "avatar": m.display_avatar.url}) - - # Prepare final stats object - total_members = guild.member_count or len(guild.members) - online_members = 0 - if intents_working := guild.members: - online_members = sum(1 for m in guild.members if m.status != discord.Status.offline) - - stats = { - "total_members": total_members, - "online_members": online_members, - "text_channels": len(guild.text_channels), - "voice_channels": len(guild.voice_channels), - "joined_today": joined_today, - "joined_trend": j_trend, - "joined_trend_value": j_trend_val, - "messages_today": messages_today, - "messages_trend": m_trend, - "messages_trend_value": m_trend_val, - "history": history, - "server_age": server_age_str, - "staff_members": staff_members_list, - "user_members": user_members_list - } - return stats - except Exception as e: - print(f"Stats error: {e}") - return { - "total_members": guild.member_count, - "online_members": 0, - "text_channels": len(guild.text_channels), - "voice_channels": len(guild.voice_channels), - "joined_today": 0, - "messages_today": 0 - } - -@dashboard_main_router.get("/guilds/{guild_id}/mega-data") -async def get_mega_data(guild_id: int, user: dict = Depends(get_current_user)): - """Consolidated endpoint for dashboard landing page (Settings, Stats, Metadata).""" - if bot_instance is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - guild = bot_instance.get_guild(guild_id) - if not guild: - raise HTTPException(status_code=404, detail="Guild not found or bot not in guild") - - member = guild.get_member(int(user["id"])) - if not member: - try: - member = await guild.fetch_member(int(user["id"])) - except: - raise HTTPException(status_code=403, detail="Nutzer nicht auf dem Server gefunden") - - if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator): - raise HTTPException(status_code=403, detail="Insufficient permissions") - - try: - # 1. Fetch Stats (reuse logic) - try: - stats = await get_guild_stats(guild_id, user) - except: - stats = {} - - # 2. Fetch Settings & Module Status - from mxmariadb import WelcomeDatabase, AntiSpamDatabase, GlobalChatDatabase, LevelDatabase, LoggingDatabase - - # Check Level System - try: - lvl_conf = LevelDatabase().get_guild_config(guild_id) - level_active = lvl_conf.get("enabled", True) if lvl_conf else False - except: - level_active = False - - # Check AntiSpam - try: - spam_conf = AntiSpamDatabase().get_spam_settings(guild_id) - antispam_active = bool(spam_conf) - except: - antispam_active = False - - # Check Welcome - try: - welcome_conf = await WelcomeDatabase().get_welcome_settings(guild_id) - welcome_active = bool(welcome_conf and welcome_conf.get("channel_id")) - except: - welcome_active = False - - # Check GlobalChat - try: - global_conf = GlobalChatDatabase().get_guild_settings(guild_id) - global_active = bool(global_conf and global_conf.get("channel_id")) - except: - global_active = False - - # Check Logging - try: - log_conf = await LoggingDatabase().get_all_log_channels(guild_id) - logging_active = len(log_conf) > 0 if log_conf else False - except: - logging_active = False - - # Check Management Modules - from mxmariadb import ManagementDatabase - db_m = ManagementDatabase() - await db_m.ensure_connection() - - # Check Auto-Responder - try: - ar_data = await db_m.get_auto_responses(guild_id) - autoresponder_active = len(ar_data) > 0 - except: autoresponder_active = False - - # Check Applications - try: - app_data = await db_m.get_questions(guild_id) - applications_active = len(app_data) > 0 - except: applications_active = False - - # Check NewsSync - try: - sync_data = await db_m.get_sync_channels() - newssync_active = any(c['guild_id'] == guild_id for c in sync_data) - except: newssync_active = False - - guild_lang = "de" - - # 3. Fetch Metadata - channels = [{"id": str(c.id), "name": c.name} for c in guild.text_channels] - roles = [ - {"id": str(r.id), "name": r.name, "color": str(r.color)} - for r in guild.roles - if not r.is_default() and not r.managed - ] - categories = [{"id": str(c.id), "name": c.name} for c in guild.categories] - voice_channels = [{"id": str(c.id), "name": c.name} for c in guild.voice_channels] - - return { - "success": True, - "data": { - "settings": { - "bot_name": bot_instance.user.name, - "prefix": "!", - "auto_mod": antispam_active, - "welcome_message": welcome_active, - "language": guild_lang, - "level_system": level_active, - "anti_spam": antispam_active, - "global_network": global_active, - "logging": logging_active, - "auto_responder": autoresponder_active, - "applications": applications_active, - "news_sync": newssync_active, - "economy": False - }, - "stats": stats, - "metadata": { - "channels": channels, - "roles": roles, - "categories": categories, - "voice_channels": voice_channels - } - } - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - +# Include all thematic sub-routers dashboard_main_router.include_router(auth_router) dashboard_main_router.include_router(settings_router) dashboard_main_router.include_router(user_router) dashboard_main_router.include_router(management_router) dashboard_main_router.include_router(cms_router) -# dashboard_main_router.include_router(router_public) # Move to main.py for root access - +dashboard_main_router.include_router(admin_router) +dashboard_main_router.include_router(guild_router) diff --git a/src/web/components/core/PremiumMarkdown.tsx b/src/web/components/core/PremiumMarkdown.tsx new file mode 100644 index 0000000..b1b2d19 --- /dev/null +++ b/src/web/components/core/PremiumMarkdown.tsx @@ -0,0 +1,130 @@ +import React, { useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; +import rehypeHighlight from "rehype-highlight"; +import rehypeSlug from "rehype-slug"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; +import remarkToc from "remark-toc"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import { Check, Copy, Link as LinkIcon } from "lucide-react"; +import { cn } from "../../lib/utils"; +import "highlight.js/styles/github-dark.css"; +import "katex/dist/katex.min.css"; + +interface PremiumMarkdownProps { + content: string; + className?: string; +} + +const CodeBlock = ({ node, inline, className, children, ...props }: any) => { + const [copied, setCopied] = useState(false); + const match = /language-(\w+)/.exec(className || ""); + const codeString = String(children).replace(/\n$/, ""); + + const handleCopy = () => { + navigator.clipboard.writeText(codeString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (inline) { + return ( + + {children} + + ); + } + + return ( +
+
+ +
+ {match && ( +
+ {match[1]} +
+ )} +
+        {children}
+      
+
+ ); +}; + +const CustomTable = ({ children }: any) => ( +
+ + {children} +
+
+); + +const CustomTh = ({ children }: any) => ( + + {children} + +); + +const CustomTd = ({ children }: any) => ( + + {children} + +); + +const CustomBlockquote = ({ children }: any) => ( +
+
+
+ {children} +
+
+); + +export function PremiumMarkdown({ content, className }: PremiumMarkdownProps) { + return ( +
+

{children}

, + h2: ({ children, ...props }) =>

{children}

, + h3: ({ children, ...props }) =>

{children}

, + }} + > + {content} +
+
+ ); +} diff --git a/src/web/dashboard/cms/CMSPostEditor.tsx b/src/web/dashboard/cms/CMSPostEditor.tsx index 822be75..84a2462 100644 --- a/src/web/dashboard/cms/CMSPostEditor.tsx +++ b/src/web/dashboard/cms/CMSPostEditor.tsx @@ -1,12 +1,20 @@ -import { useState, useEffect } from "react"; -import { X, Save, Globe, Eye, History, Image as ImageIcon, Sparkles, Hash, Clock, FileText } from "lucide-react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { + X, Save, History, FileText, + Columns, Maximize2, AlignLeft +} from "lucide-react"; import { toast } from "sonner"; import { API_URL } from "../../lib/api"; import { useAuth } from "../../components/core/AuthProvider"; import { cn } from "../../lib/utils"; import { Post, POST_TYPES, slugify, Revision } from "./cmsTypes"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; +import { PremiumMarkdown } from "../../components/core/PremiumMarkdown"; +import { CMSStatusIndicator, StatusType } from "./CMSStatusIndicator"; + +// New modular imports +import { SeoPanel } from "./editor/SEOPanel"; +import { SidebarPanel } from "./editor/SidebarPanel"; +import { buildToolbarActions, insertBlock } from "./editor/EditorToolbar"; interface CMSPostEditorProps { post: Partial; @@ -16,446 +24,337 @@ interface CMSPostEditorProps { export default function CMSPostEditor({ post: initialPost, onClose, onSave }: CMSPostEditorProps) { const { user, token } = useAuth(); - const [formData, setFormData] = useState>(initialPost); - const [activeTab, setActiveTab] = useState<'edit' | 'preview' | 'revisions'>('edit'); - const [revisions, setRevisions] = useState([]); - const [saving, setSaving] = useState(false); - const [loadingRevisions, setLoadingRevisions] = useState(false); - // Tag Selection State - const [availableTags, setAvailableTags] = useState([]); + // ─── State Management ─────────────────────────────────────────────────────── + const [formData, setFormData] = useState>({ + post_type: "dev", + is_published: false, + ...initialPost, + }); + + const [activeTab, setActiveTab] = useState<"edit" | "preview" | "revisions">("edit"); + const [editorMode, setEditorMode] = useState<"editor" | "split" | "fullscreen">("editor"); + const [saving, setSaving] = useState(false); const [tagInput, setTagInput] = useState(""); const [showTagSuggestions, setShowTagSuggestions] = useState(false); + const [revisions, setRevisions] = useState([]); + const [loadingRevisions, setLoadingRevisions] = useState(false); + const [sidebarTab, setSidebarTab] = useState<"settings" | "seo">("settings"); + const [seoScore, setSeoScore] = useState(0); + const [status, setStatus] = useState("idle"); + const [statusMessage, setStatusMessage] = useState(""); + + const textareaRef = useRef(null); + const toolbarActions = useMemo(() => buildToolbarActions(), []); + // ─── Side Effects ────────────────────────────────────────────────────────── useEffect(() => { - fetchTags(); - if (initialPost.id) { + if (activeTab === "revisions" && initialPost.id) { fetchRevisions(); } - }, [initialPost.id]); + }, [activeTab, initialPost.id]); - const fetchTags = async () => { - try { - const res = await fetch(`${API_URL}/dashboard/cms/tags`); - const json = await res.json(); - if (json.success) setAvailableTags(json.data); - } catch (err) { - console.error("Failed to fetch tags"); - } + // ─── Handlers ─────────────────────────────────────────────────────────────── + const setContent = useCallback((content: string) => { + setFormData((prev) => ({ ...prev, content })); + }, []); + + const handleTitleChange = (title: string) => { + setFormData((prev) => ({ + ...prev, + title, + slug: prev.id ? prev.slug : slugify(title), + })); }; const fetchRevisions = async () => { - if (!initialPost.id) return; setLoadingRevisions(true); try { const res = await fetch(`${API_URL}/dashboard/cms/posts/${initialPost.id}/revisions`, { - headers: { - "Authorization": `Bearer ${token}`, - "X-User-ID": user?.id || "1427994077332373554" - } + headers: { Authorization: `Bearer ${token}` } }); const data = await res.json(); - if (data.success) { - setRevisions(data.data); - } - } catch (err) { - console.error("Failed to fetch revisions"); + if (data.success) setRevisions(data.data); + } catch { + toast.error("Revisionen konnten nicht geladen werden"); } finally { setLoadingRevisions(false); } }; - const handleTitleChange = (title: string) => { - const updates: Partial = { title }; - if (!initialPost.id || !formData.slug) { - updates.slug = slugify(title); - } - setFormData(prev => ({ ...prev, ...updates })); - }; - const handleSave = async () => { if (!formData.title || !formData.content) { toast.error("Titel und Inhalt sind erforderlich"); return; } - setSaving(true); + setStatus("sending"); + setStatusMessage("Beitrag wird gespeichert..."); try { const method = initialPost.id ? "PUT" : "POST"; - const url = initialPost.id - ? `${API_URL}/dashboard/cms/posts/${initialPost.id}` + const url = initialPost.id + ? `${API_URL}/dashboard/cms/posts/${initialPost.id}` : `${API_URL}/dashboard/cms/posts`; - const res = await fetch(url, { method, - headers: { - "Authorization": `Bearer ${token}`, + headers: { + Authorization: `Bearer ${token}`, "Content-Type": "application/json", - "X-User-ID": user?.id || "1427994077332373554" + "X-User-ID": user?.id || "1427994077332373554", }, - body: JSON.stringify(formData) + body: JSON.stringify(formData), }); - const data = await res.json(); if (data.success) { toast.success(initialPost.id ? "Beitrag aktualisiert" : "Beitrag erstellt"); - onSave(); + setStatus("success"); + setStatusMessage(initialPost.id ? "Änderungen gespeichert" : "Beitrag veröffentlicht"); + + setTimeout(() => { + setStatus("idle"); + onSave(); + }, 1200); } else { toast.error(data.detail || "Fehler beim Speichern"); + setStatus("error"); + setStatusMessage(data.detail || "Speichern fehlgeschlagen"); } - } catch (err) { + } catch (err: any) { toast.error("Verbindungsfehler zum Server"); + setStatus("error"); + setStatusMessage(err.message || "Netzwerkfehler"); } finally { setSaving(false); } }; const restoreRevision = async (revId: number) => { - if (!confirm("Diesen Stand wirklich wiederherstellen? Aktuelle Änderungen gehen verloren.")) return; + if (!confirm("Diesen Stand wirklich wiederherstellen?")) return; try { const res = await fetch(`${API_URL}/dashboard/cms/posts/${initialPost.id}/restore/${revId}`, { method: "POST", - headers: { - "Authorization": `Bearer ${token}`, - "X-User-ID": user?.id || "1427994077332373554" - } + headers: { Authorization: `Bearer ${token}`, "X-User-ID": user?.id || "1427994077332373554" } }); const data = await res.json(); if (data.success) { + setFormData(data.data); + setActiveTab("edit"); toast.success("Revision wiederhergestellt"); - onSave(); // Reload everything } - } catch (err) { + } catch { toast.error("Fehler beim Wiederherstellen"); } }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.ctrlKey && e.key === "s") { + e.preventDefault(); + handleSave(); + } + if (e.key === "Tab") { + e.preventDefault(); + const ta = textareaRef.current; + if (!ta) return; + const start = ta.selectionStart; + const end = ta.selectionEnd; + ta.value = ta.value.substring(0, start) + " " + ta.value.substring(end); + ta.selectionStart = ta.selectionEnd = start + 2; + setContent(ta.value); + } + }; + + const wordCount = formData.content?.split(/\s+/).filter(Boolean).length || 0; + const charCount = formData.content?.length || 0; + const isSplit = editorMode === "split"; + const seoScoreColor = (s: number) => s >= 70 ? "green" : s >= 40 ? "yellow" : "red"; + return (
-
- {/* Header */} -
+
+ {/* ── Header ── */} +
+ setStatus("idle")} />
-
- +
+
-

+

{initialPost.id ? "Beitrag bearbeiten" : "Neuer Beitrag"}

- {formData.post_type} • {formData.title ? "Draft" : "Unbenannt"} + {formData.post_type} · {wordCount} Wörter · {charCount} Zeichen

- -
-
- - - {initialPost.id && ( - - )} + ))}
- + + +
+ )} + + -
- {/* Content */} -
- {activeTab === 'edit' && ( -
- {/* Main Fields */} -
-
- - handleTitleChange(e.target.value)} - placeholder="Einen packenden Titel wählen..." - className="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 text-lg font-bold focus:ring-1 focus:ring-primary outline-none transition-all placeholder:text-white/20 italic" - /> -
+ {/* ── Content ── */} +
+ {activeTab === "edit" && ( +
+
+ handleTitleChange(e.target.value)} + placeholder="Einen packenden Titel wählen..." + className="w-full bg-transparent text-2xl font-black italic tracking-tight focus:outline-none placeholder:text-white/15" + /> +
-
-
- - Wörter: {formData.content?.split(/\s+/).filter(Boolean).length || 0} +
+
+
+ {toolbarActions.map((action, i) => ( + + {action.divider && } + + + ))} + +
-