From c63aa53c9753a86c38baf3ce4a16f827c0ed719d Mon Sep 17 00:00:00 2001 From: Medicopter117 Date: Sat, 9 May 2026 23:27:43 +0200 Subject: [PATCH] feat: implement CMS dashboard for managing user feedback, roadmap, and team content with database integration --- mxmariadb/autodelete_db.py | 7 +- mxmariadb/cms_db.py | 276 +++++++++++++++- package-lock.json | 375 +++++++++++++++++++++- package.json | 3 +- src/api/dashboard/cms_routes.py | 326 ++++++++++++++++++- src/bot/cogs/bot/feedback.py | 58 ++++ src/web/dashboard/cms/CMSFeedbackTab.tsx | 257 +++++++++++++++ src/web/dashboard/cms/CMSMediaTab.tsx | 115 ++++++- src/web/dashboard/cms/CMSPage.tsx | 20 +- src/web/dashboard/cms/CMSRoadmapTab.tsx | 295 +++++++++++++++++ src/web/dashboard/cms/CMSTeamTab.tsx | 383 +++++++++++++++++++++++ src/web/dashboard/cms/cmsTypes.ts | 48 +++ src/web/pages/RoadmapPage.tsx | 44 ++- src/web/pages/TeamPage.tsx | 161 +++++++--- 14 files changed, 2276 insertions(+), 92 deletions(-) create mode 100644 src/bot/cogs/bot/feedback.py create mode 100644 src/web/dashboard/cms/CMSFeedbackTab.tsx create mode 100644 src/web/dashboard/cms/CMSRoadmapTab.tsx create mode 100644 src/web/dashboard/cms/CMSTeamTab.tsx diff --git a/mxmariadb/autodelete_db.py b/mxmariadb/autodelete_db.py index 4ed1071..1b34d2c 100644 --- a/mxmariadb/autodelete_db.py +++ b/mxmariadb/autodelete_db.py @@ -90,8 +90,8 @@ async def get_all(self) -> List[tuple]: async with conn.cursor() as cur: await cur.execute('SELECT channel_id, duration, exclude_pinned, exclude_bots FROM autodelete ORDER BY channel_id') results = await cur.fetchall() - # Umwandlung von Dict (aus Connector) in Tuple (für Cog Kompatibilität) - return [(r['channel_id'], r['duration'], r['exclude_pinned'], r['exclude_bots']) for r in results] + # results ist bereits eine Liste von Tupeln, keine Konvertierung nötig + return results async def get_autodelete_full(self, channel_id: int) -> Optional[tuple]: if not await self._ensure_pool(): return None @@ -99,7 +99,8 @@ async def get_autodelete_full(self, channel_id: int) -> Optional[tuple]: async with conn.cursor() as cur: await cur.execute('SELECT duration, exclude_pinned, exclude_bots FROM autodelete WHERE channel_id = %s', (channel_id,)) r = await cur.fetchone() - return (r['duration'], r['exclude_pinned'], r['exclude_bots']) if r else None + # r ist bereits ein Tupel, wir können es direkt zurückgeben + return r if r else None async def update_stats(self, channel_id: int, deleted_count: int = 0, error_count: int = 0): if not await self._ensure_pool(): return diff --git a/mxmariadb/cms_db.py b/mxmariadb/cms_db.py index 3a27cd5..5893e7a 100644 --- a/mxmariadb/cms_db.py +++ b/mxmariadb/cms_db.py @@ -71,11 +71,24 @@ async def init_db(self): size_bytes INT NOT NULL, uploader_id BIGINT NOT NULL, uploader_name VARCHAR(100), + is_stock BOOLEAN DEFAULT FALSE, uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX(uploader_id) ) """) + # Add new columns to existing table (safe migration) + for col_def in [ + "ALTER TABLE cms_posts ADD COLUMN IF NOT EXISTS excerpt TEXT NULL", + "ALTER TABLE cms_posts ADD COLUMN IF NOT EXISTS cover_image VARCHAR(500) NULL", + "ALTER TABLE cms_posts ADD COLUMN IF NOT EXISTS view_count INT DEFAULT 0", + "ALTER TABLE cms_media ADD COLUMN IF NOT EXISTS is_stock BOOLEAN DEFAULT FALSE" + ]: + try: + await cur.execute(col_def) + except Exception: + pass # Column already exists or unsupported syntax + # Revision history table await cur.execute(""" CREATE TABLE IF NOT EXISTS cms_revisions ( @@ -95,6 +108,77 @@ async def init_db(self): ) """) + # Roadmap table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS cms_roadmap ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + status ENUM('completed', 'in-progress', 'planned') DEFAULT 'planned', + description TEXT NOT NULL, + icon VARCHAR(50) DEFAULT 'Rocket', + date_info VARCHAR(50), + order_index INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """) + + # Team Categories table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS cms_team_categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + order_index INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """) + + # Team table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS cms_team ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + role VARCHAR(100) NOT NULL, + bio TEXT, + avatar VARCHAR(255), + color VARCHAR(50) DEFAULT 'bg-primary', + github VARCHAR(255), + twitter VARCHAR(255), + youtube VARCHAR(255), + instagram VARCHAR(255), + website VARCHAR(255), + order_index INT DEFAULT 0, + category_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES cms_team_categories(id) ON DELETE SET NULL + ) + """) + + # Sicheres Hinzufügen der category_id Spalte falls die Tabelle schon existiert + try: + await cur.execute("ALTER TABLE cms_team ADD COLUMN category_id INT NULL") + await cur.execute("ALTER TABLE cms_team ADD CONSTRAINT fk_team_cat FOREIGN KEY (category_id) REFERENCES cms_team_categories(id) ON DELETE SET NULL") + except aiomysql.OperationalError as e: + if e.args[0] != 1060: # 1060 is Duplicate column name + pass + + # Feedback table + + await cur.execute(""" + CREATE TABLE IF NOT EXISTS cms_feedback ( + id INT AUTO_INCREMENT PRIMARY KEY, + type ENUM('bug', 'suggestion') NOT NULL, + content TEXT NOT NULL, + user_id VARCHAR(25) NOT NULL, + user_name VARCHAR(100) NOT NULL, + guild_id VARCHAR(25), + status ENUM('new', 'read', 'accepted', 'rejected') DEFAULT 'new', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + await conn.commit() logger.info("CMS tables initialized in MariaDB") @@ -229,25 +313,40 @@ async def get_revision_by_id(self, revision_id: int): # ───────────────────────────────────────── async def create_media(self, filename: str, original_name: str, mime_type: str, - size_bytes: int, uploader_id: int, uploader_name: str): + size_bytes: int, uploader_id: int, uploader_name: str, is_stock: bool = False): async with self.pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute(""" INSERT INTO cms_media - (filename, original_name, mime_type, size_bytes, uploader_id, uploader_name) - VALUES (%s, %s, %s, %s, %s, %s) - """, (filename, original_name, mime_type, size_bytes, uploader_id, uploader_name)) + (filename, original_name, mime_type, size_bytes, uploader_id, uploader_name, is_stock) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, (filename, original_name, mime_type, size_bytes, uploader_id, uploader_name, is_stock)) await conn.commit() return True - async def get_media(self, limit: int = 100): + async def get_media(self, limit: int = 100, is_stock: bool = None): async with self.pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: - await cur.execute(""" - SELECT * FROM cms_media ORDER BY uploaded_at DESC LIMIT %s - """, (limit,)) + query = "SELECT * FROM cms_media" + params = [] + + if is_stock is not None: + query += " WHERE is_stock = %s" + params.append(is_stock) + + query += " ORDER BY uploaded_at DESC LIMIT %s" + params.append(limit) + + await cur.execute(query, tuple(params)) return await cur.fetchall() + async def update_media(self, media_id: int, is_stock: bool): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("UPDATE cms_media SET is_stock = %s WHERE id = %s", (is_stock, media_id)) + await conn.commit() + return True + async def delete_media(self, media_id: int): async with self.pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: @@ -342,3 +441,164 @@ async def delete_tag(self, tag_id: int): except Exception as e: logger.error(f"Error deleting tag: {e}") return False + + # ───────────────────────────────────────── + # ROADMAP + # ───────────────────────────────────────── + + async def get_roadmap(self): + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM cms_roadmap ORDER BY order_index ASC, created_at DESC") + return await cur.fetchall() + + async def create_roadmap_item(self, title: str, status: str, description: str, icon: str, date_info: str, order_index: int = 0): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO cms_roadmap (title, status, description, icon, date_info, order_index) + VALUES (%s, %s, %s, %s, %s, %s) + """, (title, status, description, icon, date_info, order_index)) + await conn.commit() + return True + + async def update_roadmap_item(self, item_id: int, title: str, status: str, description: str, icon: str, date_info: str, order_index: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE cms_roadmap + SET title=%s, status=%s, description=%s, icon=%s, date_info=%s, order_index=%s + WHERE id=%s + """, (title, status, description, icon, date_info, order_index, item_id)) + await conn.commit() + return True + + async def delete_roadmap_item(self, item_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM cms_roadmap WHERE id = %s", (item_id,)) + await conn.commit() + return True + + # ───────────────────────────────────────── + # TEAM CATEGORIES + # ───────────────────────────────────────── + + async def get_team_categories(self): + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM cms_team_categories ORDER BY order_index ASC") + return await cur.fetchall() + + async def create_team_category(self, data: dict): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO cms_team_categories (name, order_index) + VALUES (%s, %s) + """, (data.get("name"), data.get("order_index", 0))) + await conn.commit() + return True + + async def update_team_category(self, cat_id: int, data: dict): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE cms_team_categories + SET name=%s, order_index=%s + WHERE id=%s + """, (data.get("name"), data.get("order_index", 0), cat_id)) + await conn.commit() + return True + + async def delete_team_category(self, cat_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM cms_team_categories WHERE id = %s", (cat_id,)) + await conn.commit() + return True + + # ───────────────────────────────────────── + # TEAM + # ───────────────────────────────────────── + + async def get_team(self): + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM cms_team ORDER BY order_index ASC, created_at DESC") + return await cur.fetchall() + + async def create_team_member(self, data: dict): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO cms_team + (name, role, bio, avatar, color, github, twitter, youtube, instagram, website, order_index, category_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + data.get("name"), data.get("role"), data.get("bio"), data.get("avatar"), + data.get("color", "bg-primary"), data.get("github"), data.get("twitter"), + data.get("youtube"), data.get("instagram"), data.get("website"), + data.get("order_index", 0), data.get("category_id") + )) + await conn.commit() + return True + + async def update_team_member(self, member_id: int, data: dict): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE cms_team + SET name=%s, role=%s, bio=%s, avatar=%s, color=%s, github=%s, twitter=%s, + youtube=%s, instagram=%s, website=%s, order_index=%s, category_id=%s + WHERE id=%s + """, ( + data.get("name"), data.get("role"), data.get("bio"), data.get("avatar"), + data.get("color"), data.get("github"), data.get("twitter"), + data.get("youtube"), data.get("instagram"), data.get("website"), + data.get("order_index"), data.get("category_id"), member_id + )) + await conn.commit() + return True + + async def delete_team_member(self, member_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM cms_team WHERE id = %s", (member_id,)) + await conn.commit() + return True + + # ───────────────────────────────────────── + # FEEDBACK + # ───────────────────────────────────────── + + async def create_feedback(self, type: str, content: str, user_id: str, user_name: str, guild_id: str = None): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO cms_feedback (type, content, user_id, user_name, guild_id) + VALUES (%s, %s, %s, %s, %s) + """, (type, content, user_id, user_name, guild_id)) + await conn.commit() + return True + + async def get_all_feedback(self): + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM cms_feedback ORDER BY created_at DESC") + return await cur.fetchall() + + async def update_feedback_status(self, feedback_id: int, status: str): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("UPDATE cms_feedback SET status = %s WHERE id = %s", (status, feedback_id)) + await conn.commit() + return True + + async def delete_feedback(self, feedback_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM cms_feedback WHERE id = %s", (feedback_id,)) + await conn.commit() + return True + diff --git a/package-lock.json b/package-lock.json index 459bb76..e3dbcdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vite_react_shadcn_ts", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vite_react_shadcn_ts", - "version": "2.0.0", + "version": "2.1.0", "dependencies": { "@hookform/resolvers": "5.2.2", "@radix-ui/react-accordion": "1.2.12", @@ -54,6 +54,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "4.7.2", "react-router-dom": "7.13.1", + "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "sonner": "2.0.7", "tailwind-merge": "3.5.0", @@ -2971,6 +2972,42 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -3332,7 +3369,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -4009,6 +4045,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -4114,6 +4213,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", @@ -4914,6 +5019,127 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -4968,6 +5194,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -5102,6 +5334,16 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -5369,6 +5611,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -5701,6 +5949,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5737,6 +5995,15 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -7579,6 +7846,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7696,6 +7986,36 @@ } } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7710,6 +8030,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -7786,6 +8121,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -8041,6 +8382,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8466,6 +8813,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index bf1263d..42aa642 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "4.7.2", "react-router-dom": "7.13.1", + "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "sonner": "2.0.7", "tailwind-merge": "3.5.0", @@ -89,4 +90,4 @@ "vite": "7.3.1", "vitest": "4.0.18" } -} \ No newline at end of file +} diff --git a/src/api/dashboard/cms_routes.py b/src/api/dashboard/cms_routes.py index 95969e9..0aa3577 100644 --- a/src/api/dashboard/cms_routes.py +++ b/src/api/dashboard/cms_routes.py @@ -297,6 +297,7 @@ async def restore_revision(post_id: int, revision_id: int, request: Request, use 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) ): @@ -323,25 +324,30 @@ async def upload_media( 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 + uploader_name=username, + is_stock=stock_flag ) public_url = f"/uploads/cms/{unique_name}" - return {"success": True, "url": public_url, "filename": unique_name} + return {"success": True, "url": public_url, "filename": unique_name, "is_stock": stock_flag} @router.get("/media") -async def list_media(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: list all uploaded media files.""" +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() + media = await db.get_media(is_stock=is_stock) # Enrich with public URLs for m in media: m["url"] = f"/uploads/cms/{m['filename']}" @@ -349,6 +355,89 @@ async def list_media(request: Request, user: dict = Depends(get_maybe_user), db: 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.""" @@ -419,3 +508,230 @@ async def delete_tag(tag_id: int, request: Request, user: dict = Depends(get_may 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/bot/cogs/bot/feedback.py b/src/bot/cogs/bot/feedback.py new file mode 100644 index 0000000..c1386f2 --- /dev/null +++ b/src/bot/cogs/bot/feedback.py @@ -0,0 +1,58 @@ +import discord +from discord.ext import commands +from mxmariadb.cms_db import CMSDatabase + +class FeedbackModal(discord.ui.Modal): + def __init__(self, feedback_type: str, db: CMSDatabase): + super().__init__(title="🐛 Bug melden" if feedback_type == 'bug' else "💡 Vorschlag einreichen") + self.feedback_type = feedback_type + self.db = db + + self.content = discord.ui.InputText( + label="Beschreibe dein Anliegen", + style=discord.InputTextStyle.paragraph, + placeholder="Was möchtest du uns mitteilen? Bitte so genau wie möglich.", + required=True, + max_length=2000 + ) + self.add_item(self.content) + + async def callback(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + try: + await self.db.create_feedback( + type=self.feedback_type, + content=self.content.value, + user_id=str(interaction.user.id), + user_name=str(interaction.user), + guild_id=str(interaction.guild.id) if interaction.guild else None + ) + embed = discord.Embed( + title="✅ Vielen Dank!", + description="Dein Feedback wurde erfolgreich an unser CMS-System übermittelt. Wir schauen uns das bald an!", + color=discord.Color.green() + ) + await interaction.followup.send(embed=embed, ephemeral=True) + except Exception as e: + embed = discord.Embed( + title="❌ Fehler", + description="Es gab ein Problem beim Speichern deines Feedbacks. Bitte versuche es später erneut.", + color=discord.Color.red() + ) + await interaction.followup.send(embed=embed, ephemeral=True) + +class FeedbackCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.db = CMSDatabase() + + @discord.slash_command(name="suggest", description="💡 Sende einen Vorschlag oder eine Idee für den Bot ein.") + async def suggest(self, ctx: discord.ApplicationContext): + await ctx.send_modal(FeedbackModal('suggestion', self.db)) + + @discord.slash_command(name="bug", description="🐛 Melde einen Fehler oder Bug im Bot.") + async def bug(self, ctx: discord.ApplicationContext): + await ctx.send_modal(FeedbackModal('bug', self.db)) + +def setup(bot): + bot.add_cog(FeedbackCog(bot)) diff --git a/src/web/dashboard/cms/CMSFeedbackTab.tsx b/src/web/dashboard/cms/CMSFeedbackTab.tsx new file mode 100644 index 0000000..6cb760a --- /dev/null +++ b/src/web/dashboard/cms/CMSFeedbackTab.tsx @@ -0,0 +1,257 @@ +import { useState, useEffect } from "react"; +import { + MessageSquare, Bug, Lightbulb, CheckCircle2, + Trash2, XCircle, Map, User, Clock +} from "lucide-react"; +import { toast } from "sonner"; +import { API_URL } from "../../lib/api"; +import { useAuth } from "../../components/core/AuthProvider"; +import { FeedbackItem } from "./cmsTypes"; +import { cn } from "../../lib/utils"; + +export default function CMSFeedbackTab() { + const { token, user } = useAuth(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<"all" | "new" | "read" | "accepted" | "rejected">("all"); + + useEffect(() => { + fetchFeedback(); + }, []); + + const fetchFeedback = async () => { + try { + const res = await fetch(`${API_URL}/dashboard/cms/feedback`, { + headers: { + "Authorization": `Bearer ${token}`, + "X-User-ID": user?.id || "" + } + }); + const json = await res.json(); + if (json.success) { + setItems(json.data); + } + } catch (err) { + toast.error("Fehler beim Laden des Feedbacks"); + } finally { + setLoading(false); + } + }; + + const handleUpdateStatus = async (id: number, status: string) => { + const oldItems = [...items]; + // Optimistic Update + setItems(items.map(i => i.id === id ? { ...i, status: status as any } : i)); + + try { + const res = await fetch(`${API_URL}/dashboard/cms/feedback/${id}/status`, { + method: "PUT", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "X-User-ID": user?.id || "" + }, + body: JSON.stringify({ status }) + }); + const json = await res.json(); + if (json.success) { + toast.success("Status aktualisiert"); + } else { + setItems(oldItems); + toast.error("Fehler beim Aktualisieren"); + } + } catch (err) { + setItems(oldItems); + toast.error("Fehler beim Aktualisieren"); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm("Feedback wirklich löschen?")) return; + + const oldItems = [...items]; + // Optimistic Update + setItems(items.filter(i => i.id !== id)); + + try { + const res = await fetch(`${API_URL}/dashboard/cms/feedback/${id}`, { + method: "DELETE", + headers: { + "Authorization": `Bearer ${token}`, + "X-User-ID": user?.id || "" + } + }); + const json = await res.json(); + if (json.success) { + toast.success("Feedback gelöscht"); + } else { + setItems(oldItems); + toast.error("Fehler beim Löschen"); + } + } catch (err) { + setItems(oldItems); + toast.error("Fehler beim Löschen"); + } + }; + + const handleToRoadmap = async (id: number) => { + if (!confirm("Diesen Vorschlag direkt in die Roadmap aufnehmen?")) return; + + const oldItems = [...items]; + // Optimistic Update + setItems(items.map(i => i.id === id ? { ...i, status: 'accepted' } : i)); + + try { + const res = await fetch(`${API_URL}/dashboard/cms/feedback/${id}/to-roadmap`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "X-User-ID": user?.id || "" + } + }); + const json = await res.json(); + if (json.success) { + toast.success("Erfolgreich zur Roadmap hinzugefügt!"); + } else { + setItems(oldItems); + toast.error(json.detail || "Fehler beim Übernehmen"); + } + } catch (err) { + setItems(oldItems); + toast.error("Fehler beim Übernehmen in die Roadmap"); + } + }; + + const filteredItems = items.filter(i => filter === "all" || i.status === filter); + + const getStatusBadge = (status: string) => { + switch(status) { + case 'new': return Neu; + case 'read': return Gelesen; + case 'accepted': return In Roadmap; + case 'rejected': return Abgelehnt; + default: return null; + } + }; + + return ( +
+
+
+ +

Feedback & Bugs

+
+ + {/* Filter */} +
+ {(["all", "new", "read", "accepted", "rejected"] as const).map(f => ( + + ))} +
+
+ + {loading ? ( +
+
+
+ ) : filteredItems.length === 0 ? ( +
+ +

Kein Feedback gefunden

+
+ ) : ( +
+ {filteredItems.map((item) => ( +
+ {/* Type Icon */} +
+
+ {item.type === 'bug' ? : } +
+
+ + {/* Content */} +
+
+ {getStatusBadge(item.status)} +
+ + {item.user_name} + ({item.user_id}) +
+
+ + {new Date(item.created_at).toLocaleString('de-DE')} +
+
+ +
+ {item.content} +
+
+ + {/* Actions */} +
+ {item.status === 'new' && ( + + )} + + {item.status !== 'accepted' && ( + + )} + + {item.status !== 'rejected' && item.status !== 'accepted' && ( + + )} + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/web/dashboard/cms/CMSMediaTab.tsx b/src/web/dashboard/cms/CMSMediaTab.tsx index 666d6a7..6bf8860 100644 --- a/src/web/dashboard/cms/CMSMediaTab.tsx +++ b/src/web/dashboard/cms/CMSMediaTab.tsx @@ -1,17 +1,29 @@ import { useState, useEffect } from "react"; -import { Image as ImageIcon, Trash2, Upload, Link, FileText, Film, File as FileIcon, Search } from "lucide-react"; +import { Image as ImageIcon, Trash2, Upload, Link, FileText, Film, File as FileIcon, Search, Star } 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 { MediaItem } from "./cmsTypes"; + +interface MediaItemEx { + id: number; + filename: string; + original_name: string; + mime_type: string; + size_bytes: number; + uploader_name: string; + uploaded_at: string; + url: string; + is_stock: boolean; +} export default function CMSMediaTab() { const { user, token } = useAuth(); - const [media, setMedia] = useState([]); + const [media, setMedia] = useState([]); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [filterMode, setFilterMode] = useState<"all" | "uploads" | "stock">("all"); const fetchMedia = async () => { try { @@ -42,6 +54,9 @@ export default function CMSMediaTab() { const formData = new FormData(); formData.append("file", file); + if (filterMode === "stock") { + formData.append("is_stock", "true"); + } setUploading(true); try { @@ -70,7 +85,6 @@ export default function CMSMediaTab() { const handleDelete = async (id: number) => { if (!confirm("Datei wirklich löschen?")) return; - // Optimistic Update const oldMedia = [...media]; setMedia(media.filter(m => m.id !== id)); @@ -85,7 +99,6 @@ export default function CMSMediaTab() { const data = await res.json(); if (data.success) { toast.success("Datei gelöscht"); - // Sicherheithalber nochmal synchronisieren fetchMedia(); } else { setMedia(oldMedia); @@ -97,10 +110,47 @@ export default function CMSMediaTab() { } }; + const toggleStock = async (item: MediaItemEx) => { + const newStatus = !item.is_stock; + const oldMedia = [...media]; + + // Optimistic Update + setMedia(media.map(m => m.id === item.id ? { ...m, is_stock: newStatus } : m)); + + try { + const res = await fetch(`${API_URL}/dashboard/cms/media/${item.id}`, { + method: "PUT", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "X-User-ID": user?.id || "1427994077332373554" + }, + body: JSON.stringify({ is_stock: newStatus }) + }); + const data = await res.json(); + if (!data.success) { + setMedia(oldMedia); + toast.error("Fehler beim Aktualisieren"); + } else { + toast.success(newStatus ? "Als Stockfoto markiert" : "Markierung entfernt"); + } + } catch (err) { + setMedia(oldMedia); + toast.error("Fehler beim Aktualisieren"); + } + }; + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); toast.success("URL kopiert!"); }; + + const copyEmbedUrl = (id: number) => { + // Discord embed URL + const url = `${API_URL}/dashboard/cms/media/view/${id}`; + navigator.clipboard.writeText(url); + toast.success("Discord Embed-URL kopiert!"); + }; const getFileIcon = (mime: string) => { if (mime.startsWith('image/')) return ; @@ -109,10 +159,11 @@ export default function CMSMediaTab() { return ; }; - const filteredMedia = media.filter(m => - m.original_name.toLowerCase().includes(searchQuery.toLowerCase()) || - m.mime_type.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const filteredMedia = media.filter(m => { + const matchesSearch = m.original_name.toLowerCase().includes(searchQuery.toLowerCase()) || m.mime_type.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesTab = filterMode === "all" || (filterMode === "stock" && m.is_stock) || (filterMode === "uploads" && !m.is_stock); + return matchesSearch && matchesTab; + }); return (
@@ -145,6 +196,12 @@ export default function CMSMediaTab() {
+
+ + + +
+ {loading ? (
@@ -153,6 +210,11 @@ export default function CMSMediaTab() {
{filteredMedia.map((item) => (
+ {item.is_stock && ( +
+ +
+ )} {item.mime_type.startsWith('image/') ? (

{item.original_name}

-
+ + {/* Actions Grid */} +
+ + {item.mime_type.startsWith('image/') && ( + + )}
-
+ +
{(item.size_bytes / 1024 / 1024).toFixed(2)} MB
diff --git a/src/web/dashboard/cms/CMSPage.tsx b/src/web/dashboard/cms/CMSPage.tsx index 60aba57..7a24dbc 100644 --- a/src/web/dashboard/cms/CMSPage.tsx +++ b/src/web/dashboard/cms/CMSPage.tsx @@ -7,17 +7,28 @@ import CMSPostsTab from "./CMSPostsTab"; import CMSMediaTab from "./CMSMediaTab"; import CMSChangelogTab from "./CMSChangelogTab"; import CMSTagsTab from "./CMSTagsTab"; +import CMSRoadmapTab from "./CMSRoadmapTab"; +import CMSTeamTab from "./CMSTeamTab"; +import CMSFeedbackTab from "./CMSFeedbackTab"; import { useAuth } from "../../components/core/AuthProvider"; -import { Navigate } from "react-router-dom"; +import { Navigate, Link } from "react-router-dom"; +import { Map, Users, FileText, Hash, Image, ListTodo, ArrowLeft, LayoutDashboard, MessageSquare } from "lucide-react"; +import { useState } from "react"; +import { cn } from "../../lib/utils"; +import { motion } from "framer-motion"; const TABS = [ { id: "posts", label: "Beiträge", icon: FileText }, { id: "tags", label: "Tags", icon: Hash }, { id: "media", label: "Mediathek", icon: Image }, { id: "changelog", label: "Changelog", icon: ListTodo }, + { id: "roadmap", label: "Roadmap", icon: Map }, + { id: "team", label: "Team", icon: Users }, + { id: "feedback", label: "Feedback", icon: MessageSquare }, ] as const; type Tab = typeof TABS[number]["id"]; + export default function CMSPage() { const { user, isAuthenticated, loading } = useAuth(); const [tab, setTab] = useState("posts"); @@ -59,13 +70,13 @@ export default function CMSPage() { {/* Tabs */} -
+
{TABS.map(({ id, label, icon: Icon }) => (
diff --git a/src/web/dashboard/cms/CMSRoadmapTab.tsx b/src/web/dashboard/cms/CMSRoadmapTab.tsx new file mode 100644 index 0000000..8965b81 --- /dev/null +++ b/src/web/dashboard/cms/CMSRoadmapTab.tsx @@ -0,0 +1,295 @@ +import { useState, useEffect } from "react"; +import { + Plus, Trash2, Save, Rocket, CheckCircle2, CircleDashed, + Clock, Map, ChevronUp, ChevronDown, Edit3, X +} from "lucide-react"; +import { toast } from "sonner"; +import { API_URL } from "../../lib/api"; +import { useAuth } from "../../components/core/AuthProvider"; +import { RoadmapItem } from "./cmsTypes"; +import { cn } from "../../lib/utils"; + +const STATUS_OPTIONS = [ + { value: "completed", label: "Abgeschlossen", color: "text-emerald-400", bg: "bg-emerald-500/10", icon: CheckCircle2 }, + { value: "in-progress", label: "In Arbeit", color: "text-primary", bg: "bg-primary/10", icon: CircleDashed }, + { value: "planned", label: "Geplant", color: "text-slate-400", bg: "bg-white/5", icon: Clock }, +]; + +const ICON_OPTIONS = [ + "Rocket", "CheckCircle2", "CircleDashed", "Clock", "Sparkles", + "MessageSquare", "ShieldAlert", "Zap", "Globe", "Cpu", "LayoutDashboard", + "FileText", "Image", "BookOpen", "ArrowLeft", "Hash", "ListTodo", "Map" +]; + +export default function CMSRoadmapTab() { + const { token } = useAuth(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [editingItem, setEditingItem] = useState | null>(null); + const [showModal, setShowModal] = useState(false); + + useEffect(() => { + fetchRoadmap(); + }, []); + + const fetchRoadmap = async () => { + try { + const res = await fetch(`${API_URL}/dashboard/cms/roadmap`); + const json = await res.json(); + if (json.success) { + setItems(json.data); + } + } catch (err) { + toast.error("Fehler beim Laden der Roadmap"); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!editingItem?.title || !editingItem?.description) { + return toast.error("Bitte Titel und Beschreibung ausfüllen"); + } + + try { + const isNew = !editingItem.id; + const url = isNew + ? `${API_URL}/dashboard/cms/roadmap` + : `${API_URL}/dashboard/cms/roadmap/${editingItem.id}`; + + const res = await fetch(url, { + method: isNew ? "POST" : "PUT", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(editingItem) + }); + + const json = await res.json(); + if (json.success) { + toast.success(isNew ? "Eintrag erstellt" : "Eintrag aktualisiert"); + setShowModal(false); + setEditingItem(null); + fetchRoadmap(); + } else { + toast.error(json.detail || "Fehler beim Speichern"); + } + } catch (err) { + toast.error("Verbindungsfehler"); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm("Diesen Eintrag wirklich löschen?")) return; + try { + const res = await fetch(`${API_URL}/dashboard/cms/roadmap/${id}`, { + method: "DELETE", + headers: { "Authorization": `Bearer ${token}` } + }); + const json = await res.json(); + if (json.success) { + toast.success("Eintrag gelöscht"); + fetchRoadmap(); + } + } catch (err) { + toast.error("Fehler beim Löschen"); + } + }; + + const openEdit = (item: RoadmapItem | null = null) => { + setEditingItem(item || { + title: "", + status: "planned", + description: "", + icon: "Rocket", + date_info: "", + order_index: items.length + }); + setShowModal(true); + }; + + return ( +
+
+
+ +

Roadmap Verwaltung

+
+ +
+ + {loading ? ( +
+
+
+ ) : ( +
+ {items.map((item) => { + const status = STATUS_OPTIONS.find(s => s.value === item.status) || STATUS_OPTIONS[2]; + const StatusIcon = status.icon; + + return ( +
+
+ +
+ +
+
+ + + {status.label} + + {item.date_info} +
+

{item.title}

+

{item.description}

+
+ +
+ + +
+
+ ); + })} + + {items.length === 0 && ( +
+ +

Noch keine Roadmap-Einträge vorhanden

+
+ )} +
+ )} + + {/* Editor Modal */} + {showModal && editingItem && ( +
+
+
+

Eintrag bearbeiten

+ +
+ +
+
+
+ + setEditingItem({...editingItem, title: e.target.value})} + placeholder="z.B. Version 2.0 Launch" + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-primary outline-none transition-all" + /> +
+
+ + setEditingItem({...editingItem, date_info: e.target.value})} + placeholder="z.B. Q1 2026" + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-primary outline-none transition-all" + /> +
+
+ +
+ +
+ {STATUS_OPTIONS.map(opt => ( + + ))} +
+
+ +
+ +