From 4b50898664391dd175acebd46b908722be7d5bbb Mon Sep 17 00:00:00 2001 From: Slipstream Date: Sun, 4 May 2025 13:52:43 -0600 Subject: [PATCH] aaa --- api_service/api_models.py | 16 +- api_service/api_server.py | 7 +- .../command_customization_endpoints.py | 102 ++- api_service/dashboard_api_endpoints.py | 315 ++++++- api_service/dashboard_web/cog-management.html | 60 ++ .../dashboard_web/command-customization.html | 161 ++++ .../dashboard_web/css/cog-management.css | 115 +++ .../css/command-customization.css | 162 ++++ .../dashboard_web/css/theme-settings.css | 199 +++++ api_service/dashboard_web/index.html | 352 ++++++++ .../dashboard_web/js/cog-management.js | 466 +++++++++++ .../dashboard_web/js/command-customization.js | 781 ++++++++++++++++++ api_service/dashboard_web/js/main.js | 6 + .../dashboard_web/js/theme-settings.js | 325 ++++++++ api_service/dashboard_web/theme-settings.html | 107 +++ command_customization.py | 34 +- main.py | 20 +- settings_manager.py | 244 +++++- 18 files changed, 3417 insertions(+), 55 deletions(-) create mode 100644 api_service/dashboard_web/cog-management.html create mode 100644 api_service/dashboard_web/command-customization.html create mode 100644 api_service/dashboard_web/css/cog-management.css create mode 100644 api_service/dashboard_web/css/command-customization.css create mode 100644 api_service/dashboard_web/css/theme-settings.css create mode 100644 api_service/dashboard_web/js/cog-management.js create mode 100644 api_service/dashboard_web/js/command-customization.js create mode 100644 api_service/dashboard_web/js/theme-settings.js create mode 100644 api_service/dashboard_web/theme-settings.html diff --git a/api_service/api_models.py b/api_service/api_models.py index 3dc8cdd..1d04ce8 100644 --- a/api_service/api_models.py +++ b/api_service/api_models.py @@ -18,7 +18,7 @@ class Conversation(BaseModel): messages: List[Message] = [] created_at: datetime.datetime = Field(default_factory=datetime.datetime.now) updated_at: datetime.datetime = Field(default_factory=datetime.datetime.now) - + # Conversation-specific settings model_id: str = "openai/gpt-3.5-turbo" reasoning_enabled: bool = False @@ -28,6 +28,15 @@ class Conversation(BaseModel): web_search_enabled: bool = False system_message: Optional[str] = None +class ThemeSettings(BaseModel): + """Theme settings for the dashboard UI""" + theme_mode: str = "light" # "light", "dark", "custom" + primary_color: str = "#5865F2" # Discord blue + secondary_color: str = "#2D3748" + accent_color: str = "#7289DA" + font_family: str = "Inter, sans-serif" + custom_css: Optional[str] = None + class UserSettings(BaseModel): # General settings model_id: str = "openai/gpt-3.5-turbo" @@ -53,7 +62,10 @@ class UserSettings(BaseModel): # UI settings advanced_view_enabled: bool = False streaming_enabled: bool = True - + + # Theme settings + theme: ThemeSettings = Field(default_factory=ThemeSettings) + # Last updated timestamp last_updated: datetime.datetime = Field(default_factory=datetime.datetime.now) diff --git a/api_service/api_server.py b/api_service/api_server.py index 50fd9c7..a3d1592 100644 --- a/api_service/api_server.py +++ b/api_service/api_server.py @@ -691,14 +691,19 @@ class CommandPermission(BaseModel): class CommandPermissionsResponse(BaseModel): permissions: Dict[str, List[str]] # Command name -> List of allowed role IDs +class CommandCustomizationDetail(BaseModel): + name: str + description: Optional[str] = None + class CommandCustomizationResponse(BaseModel): - command_customizations: Dict[str, str] = {} # Original command name -> Custom command name + command_customizations: Dict[str, Dict[str, Optional[str]]] = {} # Original command name -> {name, description} group_customizations: Dict[str, str] = {} # Original group name -> Custom group name command_aliases: Dict[str, List[str]] = {} # Original command name -> List of aliases class CommandCustomizationUpdate(BaseModel): command_name: str custom_name: Optional[str] = None # If None, removes customization + custom_description: Optional[str] = None # If None, keeps existing or no description class GroupCustomizationUpdate(BaseModel): group_name: str diff --git a/api_service/command_customization_endpoints.py b/api_service/command_customization_endpoints.py index 4ab52ff..b3951ab 100644 --- a/api_service/command_customization_endpoints.py +++ b/api_service/command_customization_endpoints.py @@ -12,7 +12,7 @@ from pydantic import BaseModel try: # Try relative import first from .api_server import ( - get_dashboard_user, + get_dashboard_user, verify_dashboard_guild_admin, CommandCustomizationResponse, CommandCustomizationUpdate, @@ -23,7 +23,7 @@ try: except ImportError: # Fall back to absolute import from api_server import ( - get_dashboard_user, + get_dashboard_user, verify_dashboard_guild_admin, CommandCustomizationResponse, CommandCustomizationUpdate, @@ -63,7 +63,7 @@ async def get_command_customizations( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Settings manager not available" ) - + # Get command customizations command_customizations = await settings_manager.get_all_command_customizations(guild_id) if command_customizations is None: @@ -71,7 +71,7 @@ async def get_command_customizations( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get command customizations" ) - + # Get group customizations group_customizations = await settings_manager.get_all_group_customizations(guild_id) if group_customizations is None: @@ -79,7 +79,7 @@ async def get_command_customizations( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get group customizations" ) - + # Get command aliases command_aliases = await settings_manager.get_all_command_aliases(guild_id) if command_aliases is None: @@ -87,9 +87,17 @@ async def get_command_customizations( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get command aliases" ) - + + # Convert command_customizations to the new format + formatted_command_customizations = {} + for cmd_name, cmd_data in command_customizations.items(): + formatted_command_customizations[cmd_name] = { + 'name': cmd_data.get('name', cmd_name), + 'description': cmd_data.get('description') + } + return CommandCustomizationResponse( - command_customizations=command_customizations, + command_customizations=formatted_command_customizations, group_customizations=group_customizations, command_aliases=command_aliases ) @@ -110,7 +118,7 @@ async def set_command_customization( _user: dict = Depends(get_dashboard_user), _admin: bool = Depends(verify_dashboard_guild_admin) ): - """Set a custom name for a command in a guild.""" + """Set a custom name and/or description for a command in a guild.""" try: # Check if settings_manager is available if not settings_manager or not settings_manager.pg_pool: @@ -118,7 +126,7 @@ async def set_command_customization( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Settings manager not available" ) - + # Validate custom name format if provided if customization.custom_name is not None: if not customization.custom_name.islower() or not customization.custom_name.replace('_', '').isalnum(): @@ -126,26 +134,48 @@ async def set_command_customization( status_code=status.HTTP_400_BAD_REQUEST, detail="Custom command names must be lowercase and contain only letters, numbers, and underscores" ) - + if len(customization.custom_name) < 1 or len(customization.custom_name) > 32: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Custom command names must be between 1 and 32 characters long" ) - + + # Validate custom description if provided + if customization.custom_description is not None: + if len(customization.custom_description) > 100: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Custom command descriptions must be 100 characters or less" + ) + # Set the custom command name - success = await settings_manager.set_custom_command_name( - guild_id, - customization.command_name, + name_success = await settings_manager.set_custom_command_name( + guild_id, + customization.command_name, customization.custom_name ) - - if not success: + + if not name_success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to set custom command name" ) - + + # Set the custom command description if provided + if customization.custom_description is not None: + desc_success = await settings_manager.set_custom_command_description( + guild_id, + customization.command_name, + customization.custom_description + ) + + if not desc_success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to set custom command description" + ) + return {"message": "Command customization updated successfully"} except HTTPException: # Re-raise HTTP exceptions @@ -172,7 +202,7 @@ async def set_group_customization( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Settings manager not available" ) - + # Validate custom name format if provided if customization.custom_name is not None: if not customization.custom_name.islower() or not customization.custom_name.replace('_', '').isalnum(): @@ -180,26 +210,26 @@ async def set_group_customization( status_code=status.HTTP_400_BAD_REQUEST, detail="Custom group names must be lowercase and contain only letters, numbers, and underscores" ) - + if len(customization.custom_name) < 1 or len(customization.custom_name) > 32: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Custom group names must be between 1 and 32 characters long" ) - + # Set the custom group name success = await settings_manager.set_custom_group_name( - guild_id, - customization.group_name, + guild_id, + customization.group_name, customization.custom_name ) - + if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to set custom group name" ) - + return {"message": "Group customization updated successfully"} except HTTPException: # Re-raise HTTP exceptions @@ -226,33 +256,33 @@ async def add_command_alias( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Settings manager not available" ) - + # Validate alias format if not alias.alias_name.islower() or not alias.alias_name.replace('_', '').isalnum(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Aliases must be lowercase and contain only letters, numbers, and underscores" ) - + if len(alias.alias_name) < 1 or len(alias.alias_name) > 32: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Aliases must be between 1 and 32 characters long" ) - + # Add the command alias success = await settings_manager.add_command_alias( - guild_id, - alias.command_name, + guild_id, + alias.command_name, alias.alias_name ) - + if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to add command alias" ) - + return {"message": "Command alias added successfully"} except HTTPException: # Re-raise HTTP exceptions @@ -279,20 +309,20 @@ async def remove_command_alias( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Settings manager not available" ) - + # Remove the command alias success = await settings_manager.remove_command_alias( - guild_id, - alias.command_name, + guild_id, + alias.command_name, alias.alias_name ) - + if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to remove command alias" ) - + return {"message": "Command alias removed successfully"} except HTTPException: # Re-raise HTTP exceptions diff --git a/api_service/dashboard_api_endpoints.py b/api_service/dashboard_api_endpoints.py index b636354..6affd77 100644 --- a/api_service/dashboard_api_endpoints.py +++ b/api_service/dashboard_api_endpoints.py @@ -8,6 +8,9 @@ from typing import List, Dict, Optional, Any from fastapi import APIRouter, Depends, HTTPException, status, Body from pydantic import BaseModel, Field +# Default prefix for commands +DEFAULT_PREFIX = "!" + # Import the dependencies from api_server.py try: # Try relative import first @@ -78,6 +81,14 @@ class Message(BaseModel): role: str # 'user' or 'assistant' created_at: str +class ThemeSettings(BaseModel): + theme_mode: str = "light" # "light", "dark", "custom" + primary_color: str = "#5865F2" # Discord blue + secondary_color: str = "#2D3748" + accent_color: str = "#7289DA" + font_family: str = "Inter, sans-serif" + custom_css: Optional[str] = None + class GlobalSettings(BaseModel): system_message: Optional[str] = None character: Optional[str] = None @@ -86,6 +97,19 @@ class GlobalSettings(BaseModel): model: Optional[str] = None temperature: Optional[float] = None max_tokens: Optional[int] = None + theme: Optional[ThemeSettings] = None + +class CogInfo(BaseModel): + name: str + description: Optional[str] = None + enabled: bool = True + commands: List[Dict[str, Any]] = [] + +class CommandInfo(BaseModel): + name: str + description: Optional[str] = None + enabled: bool = True + cog_name: Optional[str] = None # --- Endpoints --- @router.get("/guilds/{guild_id}/channels", response_model=List[Channel]) @@ -424,6 +448,58 @@ async def remove_command_alias( detail=f"Error removing command alias: {str(e)}" ) +@router.get("/guilds/{guild_id}/settings", response_model=Dict[str, Any]) +async def get_guild_settings( + guild_id: int, + _user: dict = Depends(get_dashboard_user), + _admin: bool = Depends(verify_dashboard_guild_admin) +): + """Get settings for a guild.""" + try: + # Check if settings_manager is available + if not settings_manager or not settings_manager.pg_pool: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Settings manager not available" + ) + + # Get prefix + prefix = await settings_manager.get_guild_prefix(guild_id, DEFAULT_PREFIX) + + # Get welcome/goodbye settings + welcome_channel_id = await settings_manager.get_setting(guild_id, 'welcome_channel_id') + welcome_message = await settings_manager.get_setting(guild_id, 'welcome_message') + goodbye_channel_id = await settings_manager.get_setting(guild_id, 'goodbye_channel_id') + goodbye_message = await settings_manager.get_setting(guild_id, 'goodbye_message') + + # Get cog enabled statuses + cogs_enabled = await settings_manager.get_all_enabled_cogs(guild_id) + + # Get command enabled statuses + commands_enabled = await settings_manager.get_all_enabled_commands(guild_id) + + # Construct response + settings = { + "prefix": prefix, + "welcome_channel_id": welcome_channel_id, + "welcome_message": welcome_message, + "goodbye_channel_id": goodbye_channel_id, + "goodbye_message": goodbye_message, + "cogs": cogs_enabled, + "commands": commands_enabled + } + + return settings + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + log.error(f"Error getting settings for guild {guild_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error getting settings: {str(e)}" + ) + @router.patch("/guilds/{guild_id}/settings", status_code=status.HTTP_200_OK) async def update_guild_settings( guild_id: int, @@ -444,7 +520,14 @@ async def update_guild_settings( log.debug(f"Update data received: {settings_update}") success_flags = [] - core_cogs_list = {'SettingsCog', 'HelpCog'} # Core cogs that cannot be disabled + + # Get bot instance for core cogs check + try: + from discordbot import discord_bot_sync_api + bot = discord_bot_sync_api.bot_instance + core_cogs_list = bot.core_cogs if bot and hasattr(bot, 'core_cogs') else {'SettingsCog', 'HelpCog'} + except ImportError: + core_cogs_list = {'SettingsCog', 'HelpCog'} # Core cogs that cannot be disabled # Update prefix if provided if 'prefix' in settings_update: @@ -494,6 +577,14 @@ async def update_guild_settings( else: log.warning(f"Attempted to change status of core cog '{cog_name}' for guild {guild_id} - ignored.") + # Update commands if provided + if 'commands' in settings_update and isinstance(settings_update['commands'], dict): + for command_name, enabled_status in settings_update['commands'].items(): + success = await settings_manager.set_command_enabled(guild_id, command_name, enabled_status) + success_flags.append(success) + if not success: + log.error(f"Failed to update status for command '{command_name}' for guild {guild_id}") + if all(s is True for s in success_flags): # Check if all operations returned True return {"message": "Settings updated successfully."} else: @@ -628,7 +719,7 @@ async def get_global_settings( ) # Convert from UserSettings to GlobalSettings - return GlobalSettings( + global_settings = GlobalSettings( system_message=user_settings.get("system_message", ""), character=user_settings.get("character", ""), character_info=user_settings.get("character_info", ""), @@ -637,6 +728,20 @@ async def get_global_settings( temperature=user_settings.get("temperature", 0.7), max_tokens=user_settings.get("max_tokens", 1000) ) + + # Add theme settings if available + if "theme" in user_settings: + theme_data = user_settings["theme"] + global_settings.theme = ThemeSettings( + theme_mode=theme_data.get("theme_mode", "light"), + primary_color=theme_data.get("primary_color", "#5865F2"), + secondary_color=theme_data.get("secondary_color", "#2D3748"), + accent_color=theme_data.get("accent_color", "#7289DA"), + font_family=theme_data.get("font_family", "Inter, sans-serif"), + custom_css=theme_data.get("custom_css") + ) + + return global_settings except HTTPException: # Re-raise HTTP exceptions raise @@ -688,6 +793,18 @@ async def update_global_settings( custom_instructions=settings.custom_instructions ) + # Add theme settings if provided + if settings.theme: + from discordbot.api_service.api_models import ThemeSettings as ApiThemeSettings + user_settings.theme = ApiThemeSettings( + theme_mode=settings.theme.theme_mode, + primary_color=settings.theme.primary_color, + secondary_color=settings.theme.secondary_color, + accent_color=settings.theme.accent_color, + font_family=settings.theme.font_family, + custom_css=settings.theme.custom_css + ) + # Save user settings to the database updated_settings = db.save_user_settings(user_id, user_settings) if not updated_settings: @@ -708,6 +825,200 @@ async def update_global_settings( detail=f"Error updating global settings: {str(e)}" ) +# --- Cog and Command Management Endpoints --- + +@router.get("/guilds/{guild_id}/cogs", response_model=List[CogInfo]) +async def get_guild_cogs( + guild_id: int, + _user: dict = Depends(get_dashboard_user), + _admin: bool = Depends(verify_dashboard_guild_admin) +): + """Get all cogs and their commands for a guild.""" + try: + # Check if bot instance is available via discord_bot_sync_api + try: + from discordbot import discord_bot_sync_api + bot = discord_bot_sync_api.bot_instance + if not bot: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Bot instance not available" + ) + except ImportError: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Bot sync API not available" + ) + + # Get all cogs from the bot + cogs_list = [] + for cog_name, cog in bot.cogs.items(): + # Get enabled status from settings_manager + is_enabled = await settings_manager.is_cog_enabled(guild_id, cog_name, default_enabled=True) + + # Get commands for this cog + commands_list = [] + for command in cog.get_commands(): + # Get command enabled status + cmd_enabled = await settings_manager.is_command_enabled(guild_id, command.qualified_name, default_enabled=True) + commands_list.append({ + "name": command.qualified_name, + "description": command.help or "No description available", + "enabled": cmd_enabled + }) + + # Add slash commands if any + app_commands = [cmd for cmd in bot.tree.get_commands() if hasattr(cmd, 'cog') and cmd.cog and cmd.cog.qualified_name == cog_name] + for cmd in app_commands: + # Get command enabled status + cmd_enabled = await settings_manager.is_command_enabled(guild_id, cmd.name, default_enabled=True) + if not any(c["name"] == cmd.name for c in commands_list): # Avoid duplicates + commands_list.append({ + "name": cmd.name, + "description": cmd.description or "No description available", + "enabled": cmd_enabled + }) + + cogs_list.append(CogInfo( + name=cog_name, + description=cog.__doc__ or "No description available", + enabled=is_enabled, + commands=commands_list + )) + + return cogs_list + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + log.error(f"Error getting cogs for guild {guild_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error getting cogs: {str(e)}" + ) + +@router.patch("/guilds/{guild_id}/cogs/{cog_name}", status_code=status.HTTP_200_OK) +async def update_cog_status( + guild_id: int, + cog_name: str, + enabled: bool = Body(..., embed=True), + _user: dict = Depends(get_dashboard_user), + _admin: bool = Depends(verify_dashboard_guild_admin) +): + """Enable or disable a cog for a guild.""" + try: + # Check if settings_manager is available + if not settings_manager or not settings_manager.pg_pool: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Settings manager not available" + ) + + # Check if the cog exists + try: + from discordbot import discord_bot_sync_api + bot = discord_bot_sync_api.bot_instance + if not bot: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Bot instance not available" + ) + + if cog_name not in bot.cogs: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Cog '{cog_name}' not found" + ) + + # Check if it's a core cog + if cog_name in bot.core_cogs: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Core cog '{cog_name}' cannot be disabled" + ) + except ImportError: + # If we can't import the bot, we'll just assume the cog exists + log.warning("Bot sync API not available, skipping cog existence check") + + # Update the cog enabled status + success = await settings_manager.set_cog_enabled(guild_id, cog_name, enabled) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update cog '{cog_name}' status" + ) + + return {"message": f"Cog '{cog_name}' {'enabled' if enabled else 'disabled'} successfully"} + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + log.error(f"Error updating cog status for guild {guild_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error updating cog status: {str(e)}" + ) + +@router.patch("/guilds/{guild_id}/commands/{command_name}", status_code=status.HTTP_200_OK) +async def update_command_status( + guild_id: int, + command_name: str, + enabled: bool = Body(..., embed=True), + _user: dict = Depends(get_dashboard_user), + _admin: bool = Depends(verify_dashboard_guild_admin) +): + """Enable or disable a command for a guild.""" + try: + # Check if settings_manager is available + if not settings_manager or not settings_manager.pg_pool: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Settings manager not available" + ) + + # Check if the command exists + try: + from discordbot import discord_bot_sync_api + bot = discord_bot_sync_api.bot_instance + if not bot: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Bot instance not available" + ) + + # Check if it's a prefix command + command = bot.get_command(command_name) + if not command: + # Check if it's an app command + app_commands = [cmd for cmd in bot.tree.get_commands() if cmd.name == command_name] + if not app_commands: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Command '{command_name}' not found" + ) + except ImportError: + # If we can't import the bot, we'll just assume the command exists + log.warning("Bot sync API not available, skipping command existence check") + + # Update the command enabled status + success = await settings_manager.set_command_enabled(guild_id, command_name, enabled) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update command '{command_name}' status" + ) + + return {"message": f"Command '{command_name}' {'enabled' if enabled else 'disabled'} successfully"} + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + log.error(f"Error updating command status for guild {guild_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error updating command status: {str(e)}" + ) + # --- Conversations Endpoints --- @router.get("/conversations", response_model=List[Conversation]) diff --git a/api_service/dashboard_web/cog-management.html b/api_service/dashboard_web/cog-management.html new file mode 100644 index 0000000..49ce6cd --- /dev/null +++ b/api_service/dashboard_web/cog-management.html @@ -0,0 +1,60 @@ + + diff --git a/api_service/dashboard_web/command-customization.html b/api_service/dashboard_web/command-customization.html new file mode 100644 index 0000000..7d10624 --- /dev/null +++ b/api_service/dashboard_web/command-customization.html @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + diff --git a/api_service/dashboard_web/css/cog-management.css b/api_service/dashboard_web/css/cog-management.css new file mode 100644 index 0000000..0662a1f --- /dev/null +++ b/api_service/dashboard_web/css/cog-management.css @@ -0,0 +1,115 @@ +/* Cog Management Styles */ + +.cog-card, .command-card { + background-color: var(--card-bg); + border-color: var(--border-color); + transition: all 0.2s ease; +} + +.cog-card:hover, .command-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.cog-badge { + background-color: var(--primary-color-light); + color: var(--primary-color-dark); +} + +.command-count { + background-color: var(--secondary-color-light); + color: var(--secondary-color-dark); +} + +.cogs-list-container, .commands-list-container { + max-height: 500px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 0.25rem; +} + +/* Loading container */ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.loading-spinner { + border: 4px solid rgba(0, 0, 0, 0.1); + border-radius: 50%; + border-top: 4px solid var(--primary-color); + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Checkbox styling */ +input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 1.25rem; + height: 1.25rem; + border: 2px solid var(--border-color); + border-radius: 0.25rem; + background-color: var(--card-bg); + display: inline-block; + position: relative; + margin-right: 0.5rem; + vertical-align: middle; + cursor: pointer; +} + +input[type="checkbox"]:checked { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +input[type="checkbox"]:checked::after { + content: ""; + position: absolute; + left: 0.3rem; + top: 0.1rem; + width: 0.5rem; + height: 0.8rem; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +input[type="checkbox"]:disabled { + background-color: var(--disabled-bg); + border-color: var(--disabled-border); + cursor: not-allowed; +} + +input[type="checkbox"]:disabled:checked { + background-color: var(--disabled-checked-bg); +} + +input[type="checkbox"]:disabled:checked::after { + border-color: var(--disabled-checked-color); +} + +/* Grid layout for larger screens */ +@media (min-width: 768px) { + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* Feedback messages */ +.text-green-600 { + color: #059669; +} + +.text-red-600 { + color: #dc2626; +} diff --git a/api_service/dashboard_web/css/command-customization.css b/api_service/dashboard_web/css/command-customization.css new file mode 100644 index 0000000..630b62e --- /dev/null +++ b/api_service/dashboard_web/css/command-customization.css @@ -0,0 +1,162 @@ +/* Command Customization CSS */ + +.command-list { + margin-top: var(--spacing-4); +} + +.command-item { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-3); + overflow: hidden; +} + +.command-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-3) var(--spacing-4); + background-color: var(--light-bg); + border-bottom: 1px solid var(--border-color); +} + +.command-name { + margin: 0; + font-weight: 600; +} + +.command-actions { + display: flex; + gap: var(--spacing-2); +} + +.command-details, .group-details { + padding: var(--spacing-4); +} + +.command-description { + margin-top: 0; + margin-bottom: var(--spacing-3); + color: var(--text-secondary); +} + +.command-customization, .group-customization { + margin-top: var(--spacing-3); + padding-top: var(--spacing-3); + border-top: 1px solid var(--border-color); +} + +/* Alias styles */ +.alias-item { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-3); + overflow: hidden; +} + +.alias-header { + padding: var(--spacing-3) var(--spacing-4); + background-color: var(--light-bg); + border-bottom: 1px solid var(--border-color); +} + +.alias-list { + padding: var(--spacing-3) var(--spacing-4); +} + +.alias-tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + list-style: none; + padding: 0; + margin: 0; +} + +.alias-tag { + display: inline-flex; + align-items: center; + background-color: var(--primary-color); + color: white; + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius-sm); + font-size: 0.9rem; +} + +.alias-name { + margin-right: var(--spacing-1); +} + +.remove-alias-btn { + background: none; + border: none; + color: white; + font-size: 1.2rem; + line-height: 1; + padding: 0; + cursor: pointer; + opacity: 0.7; +} + +.remove-alias-btn:hover { + opacity: 1; +} + +/* Search container */ +.search-container { + margin-bottom: var(--spacing-4); +} + +#command-search { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + width: 100%; +} + +/* Dark mode styles */ +body.dark-mode .command-header, +body.dark-mode .alias-header { + background-color: var(--dark-bg); + border-bottom-color: var(--border-color); +} + +body.dark-mode .command-item, +body.dark-mode .alias-item { + border-color: var(--border-color); +} + +body.dark-mode .command-description { + color: var(--text-secondary); +} + +body.dark-mode .command-customization, +body.dark-mode .group-customization { + border-top-color: var(--border-color); +} + +/* Custom mode styles */ +body.custom-mode .alias-tag { + background-color: var(--primary-color); +} + +/* Responsive styles */ +@media (max-width: 768px) { + .command-header { + flex-direction: column; + align-items: flex-start; + } + + .command-actions { + margin-top: var(--spacing-2); + } + + .flex-row { + flex-direction: column; + } + + .flex-col.mr-2 { + margin-right: 0; + margin-bottom: var(--spacing-2); + } +} diff --git a/api_service/dashboard_web/css/theme-settings.css b/api_service/dashboard_web/css/theme-settings.css new file mode 100644 index 0000000..5dda53e --- /dev/null +++ b/api_service/dashboard_web/css/theme-settings.css @@ -0,0 +1,199 @@ +/* Theme Settings CSS */ + +/* Color Picker Container */ +.color-picker-container { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +input[type="color"] { + width: 40px; + height: 40px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background-color: white; + cursor: pointer; + padding: 0; +} + +.color-text-input { + width: 100px; + font-family: monospace; +} + +/* Theme Preview */ +.theme-preview { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + margin-top: var(--spacing-4); +} + +.preview-header { + background-color: var(--primary-color); + color: white; + padding: var(--spacing-4); + display: flex; + justify-content: space-between; + align-items: center; +} + +.preview-title { + font-weight: 600; + font-size: 1.1rem; +} + +.preview-button { + background-color: rgba(255, 255, 255, 0.2); + padding: var(--spacing-2) var(--spacing-4); + border-radius: var(--radius-md); + cursor: pointer; +} + +.preview-content { + padding: var(--spacing-4); + background-color: var(--light-bg); +} + +.preview-card { + background-color: white; + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.preview-card-header { + padding: var(--spacing-3) var(--spacing-4); + border-bottom: 1px solid var(--border-color); + font-weight: 600; +} + +.preview-card-body { + padding: var(--spacing-4); +} + +.preview-form-control { + height: 40px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-4); +} + +.preview-button-primary { + display: inline-block; + background-color: var(--primary-color); + color: white; + padding: var(--spacing-2) var(--spacing-4); + border-radius: var(--radius-md); + margin-right: var(--spacing-2); + cursor: pointer; +} + +.preview-button-secondary { + display: inline-block; + background-color: var(--secondary-color); + color: white; + padding: var(--spacing-2) var(--spacing-4); + border-radius: var(--radius-md); + cursor: pointer; +} + +/* Dark Mode Preview */ +.theme-preview.dark-mode { + --preview-bg: #1A202C; + --preview-card-bg: #2D3748; + --preview-text: #F7FAFC; + --preview-border: #4A5568; +} + +.theme-preview.dark-mode .preview-content { + background-color: var(--preview-bg); +} + +.theme-preview.dark-mode .preview-card { + background-color: var(--preview-card-bg); + color: var(--preview-text); +} + +.theme-preview.dark-mode .preview-card-header { + border-bottom-color: var(--preview-border); +} + +.theme-preview.dark-mode .preview-form-control { + border-color: var(--preview-border); + background-color: #4A5568; +} + +/* Custom Mode Preview */ +.theme-preview.custom-mode .preview-header { + background-color: var(--primary-color); +} + +.theme-preview.custom-mode .preview-button-primary { + background-color: var(--primary-color); +} + +.theme-preview.custom-mode .preview-button-secondary { + background-color: var(--secondary-color); +} + +/* Dark Mode for Dashboard */ +body.dark-mode { + --light-bg: #1A202C; + --dark-bg: #171923; + --card-bg: #2D3748; + --text-primary: #F7FAFC; + --text-secondary: #CBD5E0; + --border-color: #4A5568; +} + +body.dark-mode .card { + background-color: var(--card-bg); + color: var(--text-primary); +} + +body.dark-mode input[type="text"], +body.dark-mode input[type="number"], +body.dark-mode input[type="password"], +body.dark-mode input[type="email"], +body.dark-mode input[type="search"], +body.dark-mode select, +body.dark-mode textarea { + background-color: #4A5568; + color: var(--text-primary); + border-color: #718096; +} + +body.dark-mode .sidebar { + background-color: #171923; +} + +body.dark-mode .header { + background-color: #2D3748; + border-bottom-color: #4A5568; +} + +/* Custom Mode for Dashboard */ +body.custom-mode { + --primary-color: var(--primary-color); + --secondary-color: var(--secondary-color); + --accent-color: var(--accent-color); + font-family: var(--font-family); +} + +body.custom-mode .btn-primary { + background-color: var(--primary-color); +} + +body.custom-mode .btn-secondary { + background-color: var(--secondary-color); +} + +body.custom-mode .sidebar { + background-color: var(--secondary-color); +} + +body.custom-mode .nav-item.active { + background-color: var(--primary-color); +} diff --git a/api_service/dashboard_web/index.html b/api_service/dashboard_web/index.html index f98bd60..0a71795 100644 --- a/api_service/dashboard_web/index.html +++ b/api_service/dashboard_web/index.html @@ -12,6 +12,9 @@ + + + @@ -46,6 +49,10 @@ Modules + + + Cog Management + Permissions @@ -54,6 +61,14 @@ AI Settings + + + Theme Settings + + + + Command Customization + + + + + + + + + + + + + + + + + + + + + + + @@ -376,5 +725,8 @@ + + + diff --git a/api_service/dashboard_web/js/cog-management.js b/api_service/dashboard_web/js/cog-management.js new file mode 100644 index 0000000..1f28379 --- /dev/null +++ b/api_service/dashboard_web/js/cog-management.js @@ -0,0 +1,466 @@ +/** + * Cog Management JavaScript + * Handles cog and command enabling/disabling functionality + */ + +// Global variables +let cogsData = []; +let commandsData = {}; +let selectedGuildId = null; +let cogManagementLoaded = false; + +// Initialize cog management when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + initCogManagement(); +}); + +/** + * Initialize cog management functionality + */ +function initCogManagement() { + // Get DOM elements + const cogGuildSelect = document.getElementById('cog-guild-select'); + const cogFilter = document.getElementById('cog-filter'); + const saveCogsButton = document.getElementById('save-cogs-button'); + const saveCommandsButton = document.getElementById('save-commands-button'); + const navCogManagement = document.getElementById('nav-cog-management'); + + // Add event listener for cog management tab + if (navCogManagement) { + navCogManagement.addEventListener('click', () => { + // Show cog management section + showSection('cog-management'); + + // Load guilds if not already loaded + if (!cogManagementLoaded) { + loadGuildsForCogManagement(); + cogManagementLoaded = true; + } + }); + } + + // Add event listener for guild select + if (cogGuildSelect) { + cogGuildSelect.addEventListener('change', () => { + selectedGuildId = cogGuildSelect.value; + if (selectedGuildId) { + loadCogsAndCommands(selectedGuildId); + } else { + // Hide content if no guild selected + document.getElementById('cog-management-content').style.display = 'none'; + } + }); + } + + // Add event listener for cog filter + if (cogFilter) { + cogFilter.addEventListener('change', () => { + filterCommands(cogFilter.value); + }); + } + + // Add event listener for save cogs button + if (saveCogsButton) { + saveCogsButton.addEventListener('click', () => { + saveCogsSettings(); + }); + } + + // Add event listener for save commands button + if (saveCommandsButton) { + saveCommandsButton.addEventListener('click', () => { + saveCommandsSettings(); + }); + } +} + +/** + * Load guilds for cog management + */ +function loadGuildsForCogManagement() { + const cogGuildSelect = document.getElementById('cog-guild-select'); + + // Show loading state + cogGuildSelect.disabled = true; + cogGuildSelect.innerHTML = ''; + + // Fetch guilds from API + API.get('/dashboard/api/guilds') + .then(guilds => { + // Clear loading state + cogGuildSelect.innerHTML = ''; + + // Add guilds to select + guilds.forEach(guild => { + const option = document.createElement('option'); + option.value = guild.id; + option.textContent = guild.name; + cogGuildSelect.appendChild(option); + }); + + // Enable select + cogGuildSelect.disabled = false; + }) + .catch(error => { + console.error('Error loading guilds:', error); + cogGuildSelect.innerHTML = ''; + cogGuildSelect.disabled = false; + Toast.error('Failed to load servers. Please try again.'); + }); +} + +/** + * Load cogs and commands for a guild + * @param {string} guildId - The guild ID + */ +function loadCogsAndCommands(guildId) { + // Show loading state + document.getElementById('cog-management-loading').style.display = 'flex'; + document.getElementById('cog-management-content').style.display = 'none'; + + // Fetch cogs and commands from API + API.get(`/dashboard/api/guilds/${guildId}/cogs`) + .then(data => { + // Store data + cogsData = data; + + // Populate cogs list + populateCogsUI(data); + + // Populate commands list + populateCommandsUI(data); + + // Hide loading state + document.getElementById('cog-management-loading').style.display = 'none'; + document.getElementById('cog-management-content').style.display = 'block'; + }) + .catch(error => { + console.error('Error loading cogs and commands:', error); + document.getElementById('cog-management-loading').style.display = 'none'; + Toast.error('Failed to load cogs and commands. Please try again.'); + }); +} + +/** + * Populate cogs UI + * @param {Array} cogs - Array of cog objects + */ +function populateCogsUI(cogs) { + const cogsList = document.getElementById('cogs-list'); + const cogFilter = document.getElementById('cog-filter'); + + // Clear previous content + cogsList.innerHTML = ''; + + // Clear filter options except "All Cogs" + cogFilter.innerHTML = ''; + + // Add cogs to list + cogs.forEach(cog => { + // Create cog card + const cogCard = document.createElement('div'); + cogCard.className = 'cog-card p-4 border rounded'; + + // Create cog header + const cogHeader = document.createElement('div'); + cogHeader.className = 'cog-header flex items-center justify-between mb-2'; + + // Create cog checkbox + const cogCheckbox = document.createElement('div'); + cogCheckbox.className = 'cog-checkbox flex items-center'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = `cog-${cog.name}`; + checkbox.className = 'mr-2'; + checkbox.checked = cog.enabled; + checkbox.dataset.cogName = cog.name; + + // Disable checkbox for core cogs + if (cog.name === 'SettingsCog' || cog.name === 'HelpCog') { + checkbox.disabled = true; + checkbox.title = 'Core cogs cannot be disabled'; + } + + const label = document.createElement('label'); + label.htmlFor = `cog-${cog.name}`; + label.textContent = cog.name; + label.className = 'font-medium'; + + cogCheckbox.appendChild(checkbox); + cogCheckbox.appendChild(label); + + // Create command count badge + const commandCount = document.createElement('span'); + commandCount.className = 'command-count bg-gray-200 text-gray-800 px-2 py-1 rounded text-xs'; + commandCount.textContent = `${cog.commands.length} commands`; + + cogHeader.appendChild(cogCheckbox); + cogHeader.appendChild(commandCount); + + // Create cog description + const cogDescription = document.createElement('p'); + cogDescription.className = 'cog-description text-sm text-gray-600 mt-1'; + cogDescription.textContent = cog.description || 'No description available'; + + // Add elements to cog card + cogCard.appendChild(cogHeader); + cogCard.appendChild(cogDescription); + + // Add cog card to list + cogsList.appendChild(cogCard); + + // Add cog to filter options + const option = document.createElement('option'); + option.value = cog.name; + option.textContent = cog.name; + cogFilter.appendChild(option); + }); +} + +/** + * Populate commands UI + * @param {Array} cogs - Array of cog objects + */ +function populateCommandsUI(cogs) { + const commandsList = document.getElementById('commands-list'); + + // Clear previous content + commandsList.innerHTML = ''; + + // Create a flat list of all commands with their cog + commandsData = {}; + + cogs.forEach(cog => { + cog.commands.forEach(command => { + // Store command data with cog name + commandsData[command.name] = { + ...command, + cog_name: cog.name + }; + + // Create command card + const commandCard = createCommandCard(command, cog.name); + + // Add command card to list + commandsList.appendChild(commandCard); + }); + }); +} + +/** + * Create a command card element + * @param {Object} command - Command object + * @param {string} cogName - Name of the cog the command belongs to + * @returns {HTMLElement} Command card element + */ +function createCommandCard(command, cogName) { + // Create command card + const commandCard = document.createElement('div'); + commandCard.className = 'command-card p-4 border rounded'; + commandCard.dataset.cogName = cogName; + + // Create command header + const commandHeader = document.createElement('div'); + commandHeader.className = 'command-header flex items-center justify-between mb-2'; + + // Create command checkbox + const commandCheckbox = document.createElement('div'); + commandCheckbox.className = 'command-checkbox flex items-center'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = `command-${command.name}`; + checkbox.className = 'mr-2'; + checkbox.checked = command.enabled; + checkbox.dataset.commandName = command.name; + + const label = document.createElement('label'); + label.htmlFor = `command-${command.name}`; + label.textContent = command.name; + label.className = 'font-medium'; + + commandCheckbox.appendChild(checkbox); + commandCheckbox.appendChild(label); + + // Create cog badge + const cogBadge = document.createElement('span'); + cogBadge.className = 'cog-badge bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs'; + cogBadge.textContent = cogName; + + commandHeader.appendChild(commandCheckbox); + commandHeader.appendChild(cogBadge); + + // Create command description + const commandDescription = document.createElement('p'); + commandDescription.className = 'command-description text-sm text-gray-600 mt-1'; + commandDescription.textContent = command.description || 'No description available'; + + // Add elements to command card + commandCard.appendChild(commandHeader); + commandCard.appendChild(commandDescription); + + return commandCard; +} + +/** + * Filter commands by cog + * @param {string} cogName - Name of the cog to filter by, or "all" for all cogs + */ +function filterCommands(cogName) { + const commandCards = document.querySelectorAll('.command-card'); + + commandCards.forEach(card => { + if (cogName === 'all' || card.dataset.cogName === cogName) { + card.style.display = 'block'; + } else { + card.style.display = 'none'; + } + }); +} + +/** + * Save cogs settings + */ +function saveCogsSettings() { + if (!selectedGuildId) return; + + // Show loading state + const saveButton = document.getElementById('save-cogs-button'); + saveButton.disabled = true; + saveButton.textContent = 'Saving...'; + + // Get cog settings + const cogsPayload = {}; + const cogCheckboxes = document.querySelectorAll('#cogs-list input[type="checkbox"]'); + + cogCheckboxes.forEach(checkbox => { + if (!checkbox.disabled) { + cogsPayload[checkbox.dataset.cogName] = checkbox.checked; + } + }); + + // Send request to API + API.patch(`/dashboard/api/guilds/${selectedGuildId}/settings`, { + cogs: cogsPayload + }) + .then(() => { + // Reset button state + saveButton.disabled = false; + saveButton.textContent = 'Save Cog Settings'; + + // Show success message + document.getElementById('cogs-feedback').textContent = 'Cog settings saved successfully!'; + document.getElementById('cogs-feedback').className = 'mt-2 text-green-600'; + + // Clear message after 3 seconds + setTimeout(() => { + document.getElementById('cogs-feedback').textContent = ''; + document.getElementById('cogs-feedback').className = 'mt-2'; + }, 3000); + + Toast.success('Cog settings saved successfully!'); + }) + .catch(error => { + console.error('Error saving cog settings:', error); + + // Reset button state + saveButton.disabled = false; + saveButton.textContent = 'Save Cog Settings'; + + // Show error message + document.getElementById('cogs-feedback').textContent = 'Error saving cog settings. Please try again.'; + document.getElementById('cogs-feedback').className = 'mt-2 text-red-600'; + + Toast.error('Failed to save cog settings. Please try again.'); + }); +} + +/** + * Save commands settings + */ +function saveCommandsSettings() { + if (!selectedGuildId) return; + + // Show loading state + const saveButton = document.getElementById('save-commands-button'); + saveButton.disabled = true; + saveButton.textContent = 'Saving...'; + + // Get command settings + const commandsPayload = {}; + const commandCheckboxes = document.querySelectorAll('#commands-list input[type="checkbox"]'); + + commandCheckboxes.forEach(checkbox => { + commandsPayload[checkbox.dataset.commandName] = checkbox.checked; + }); + + // Send request to API + API.patch(`/dashboard/api/guilds/${selectedGuildId}/settings`, { + commands: commandsPayload + }) + .then(() => { + // Reset button state + saveButton.disabled = false; + saveButton.textContent = 'Save Command Settings'; + + // Show success message + document.getElementById('commands-feedback').textContent = 'Command settings saved successfully!'; + document.getElementById('commands-feedback').className = 'mt-2 text-green-600'; + + // Clear message after 3 seconds + setTimeout(() => { + document.getElementById('commands-feedback').textContent = ''; + document.getElementById('commands-feedback').className = 'mt-2'; + }, 3000); + + Toast.success('Command settings saved successfully!'); + }) + .catch(error => { + console.error('Error saving command settings:', error); + + // Reset button state + saveButton.disabled = false; + saveButton.textContent = 'Save Command Settings'; + + // Show error message + document.getElementById('commands-feedback').textContent = 'Error saving command settings. Please try again.'; + document.getElementById('commands-feedback').className = 'mt-2 text-red-600'; + + Toast.error('Failed to save command settings. Please try again.'); + }); +} + +/** + * Show a specific section and hide others + * @param {string} sectionId - ID of the section to show + */ +function showSection(sectionId) { + // Get all sections + const sections = document.querySelectorAll('.dashboard-section'); + + // Hide all sections + sections.forEach(section => { + section.style.display = 'none'; + }); + + // Get all nav buttons + const navButtons = document.querySelectorAll('.nav-button'); + + // Remove active class from all nav buttons + navButtons.forEach(button => { + button.classList.remove('active'); + }); + + // Show the selected section and activate the corresponding nav button + const selectedSection = document.getElementById(`${sectionId}-section`); + const selectedNavButton = document.getElementById(`nav-${sectionId}`); + + if (selectedSection) { + selectedSection.style.display = 'block'; + } + + if (selectedNavButton) { + selectedNavButton.classList.add('active'); + } +} diff --git a/api_service/dashboard_web/js/command-customization.js b/api_service/dashboard_web/js/command-customization.js new file mode 100644 index 0000000..f54274e --- /dev/null +++ b/api_service/dashboard_web/js/command-customization.js @@ -0,0 +1,781 @@ +/** + * Command Customization JavaScript + * Handles command customization functionality for the dashboard + */ + +// Initialize command customization when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + initCommandCustomization(); +}); + +/** + * Initialize command customization + */ +function initCommandCustomization() { + // Add event listener to command search input + const commandSearch = document.getElementById('command-search'); + if (commandSearch) { + commandSearch.addEventListener('input', filterCommands); + } + + // Add event listener to sync commands button + const syncCommandsButton = document.getElementById('sync-commands-button'); + if (syncCommandsButton) { + syncCommandsButton.addEventListener('click', syncCommands); + } + + // Add event listener to add alias button + const addAliasButton = document.getElementById('add-alias-button'); + if (addAliasButton) { + addAliasButton.addEventListener('click', addAlias); + } + + // Load command customizations when the section is shown + const navItem = document.querySelector('a[data-section="command-customization-section"]'); + if (navItem) { + navItem.addEventListener('click', () => { + loadCommandCustomizations(); + }); + } +} + +/** + * Load command customizations from API + */ +async function loadCommandCustomizations() { + try { + // Show loading spinners + document.getElementById('command-list').innerHTML = '
'; + document.getElementById('group-list').innerHTML = '
'; + document.getElementById('alias-list').innerHTML = '
'; + + // Get the current guild ID + const guildId = getCurrentGuildId(); + if (!guildId) { + showToast('error', 'Error', 'No guild selected'); + return; + } + + // Fetch command customizations from API + const response = await fetch(`/dashboard/commands/customizations/${guildId}`); + if (!response.ok) { + throw new Error('Failed to load command customizations'); + } + + const data = await response.json(); + + // Render command customizations + renderCommandCustomizations(data.command_customizations); + + // Render group customizations + renderGroupCustomizations(data.group_customizations); + + // Render command aliases + renderCommandAliases(data.command_aliases); + + // Populate command select for aliases + populateCommandSelect(Object.keys(data.command_customizations)); + } catch (error) { + console.error('Error loading command customizations:', error); + showToast('error', 'Error', 'Failed to load command customizations'); + + // Show error message in lists + document.getElementById('command-list').innerHTML = '
Failed to load command customizations
'; + document.getElementById('group-list').innerHTML = '
Failed to load group customizations
'; + document.getElementById('alias-list').innerHTML = '
Failed to load command aliases
'; + } +} + +/** + * Render command customizations + * @param {Object} commandCustomizations - Command customizations object + */ +function renderCommandCustomizations(commandCustomizations) { + const commandList = document.getElementById('command-list'); + commandList.innerHTML = ''; + + if (Object.keys(commandCustomizations).length === 0) { + commandList.innerHTML = '
No commands found
'; + return; + } + + // Sort commands alphabetically + const sortedCommands = Object.keys(commandCustomizations).sort(); + + // Create command items + sortedCommands.forEach(commandName => { + const customization = commandCustomizations[commandName]; + const commandItem = createCommandItem(commandName, customization); + commandList.appendChild(commandItem); + }); +} + +/** + * Create a command item element + * @param {string} commandName - Original command name + * @param {Object} customization - Command customization object + * @returns {HTMLElement} Command item element + */ +function createCommandItem(commandName, customization) { + // Clone the template + const template = document.getElementById('command-item-template'); + const commandItem = template.content.cloneNode(true).querySelector('.command-item'); + + // Set command name + const nameElement = commandItem.querySelector('.command-name'); + nameElement.textContent = commandName; + if (customization.name && customization.name !== commandName) { + nameElement.textContent = `${customization.name} (${commandName})`; + } + + // Set command description + const descriptionElement = commandItem.querySelector('.command-description'); + descriptionElement.textContent = customization.description || 'No description available'; + + // Set custom name input value + const customNameInput = commandItem.querySelector('.custom-command-name'); + customNameInput.value = customization.name || ''; + customNameInput.placeholder = commandName; + + // Set custom description input value + const customDescriptionInput = commandItem.querySelector('.custom-command-description'); + customDescriptionInput.value = customization.description || ''; + + // Add event listeners to buttons + const editButton = commandItem.querySelector('.edit-command-btn'); + const resetButton = commandItem.querySelector('.reset-command-btn'); + const saveButton = commandItem.querySelector('.save-command-btn'); + const cancelButton = commandItem.querySelector('.cancel-command-btn'); + const customizationDiv = commandItem.querySelector('.command-customization'); + + editButton.addEventListener('click', () => { + customizationDiv.style.display = 'block'; + editButton.style.display = 'none'; + }); + + resetButton.addEventListener('click', () => { + resetCommandCustomization(commandName); + }); + + saveButton.addEventListener('click', () => { + saveCommandCustomization( + commandName, + customNameInput.value, + customDescriptionInput.value, + customizationDiv, + editButton, + nameElement, + descriptionElement + ); + }); + + cancelButton.addEventListener('click', () => { + customizationDiv.style.display = 'none'; + editButton.style.display = 'inline-block'; + + // Reset input values + customNameInput.value = customization.name || ''; + customDescriptionInput.value = customization.description || ''; + }); + + // Add data attribute for filtering + commandItem.dataset.commandName = commandName.toLowerCase(); + + return commandItem; +} + +/** + * Save command customization + * @param {string} commandName - Original command name + * @param {string} customName - Custom command name + * @param {string} customDescription - Custom command description + * @param {HTMLElement} customizationDiv - Command customization div + * @param {HTMLElement} editButton - Edit button + * @param {HTMLElement} nameElement - Command name element + * @param {HTMLElement} descriptionElement - Command description element + */ +async function saveCommandCustomization( + commandName, + customName, + customDescription, + customizationDiv, + editButton, + nameElement, + descriptionElement +) { + try { + // Validate custom name format if provided + if (customName && (!/^[a-z][a-z0-9_]*$/.test(customName) || customName.length > 32)) { + showToast('error', 'Error', 'Custom command names must be lowercase, start with a letter, and contain only letters, numbers, and underscores (max 32 characters)'); + return; + } + + // Validate custom description if provided + if (customDescription && customDescription.length > 100) { + showToast('error', 'Error', 'Custom command descriptions must be 100 characters or less'); + return; + } + + // Get the current guild ID + const guildId = getCurrentGuildId(); + if (!guildId) { + showToast('error', 'Error', 'No guild selected'); + return; + } + + // Prepare request data + const requestData = { + command_name: commandName, + custom_name: customName || null, + custom_description: customDescription || null + }; + + // Send request to API + const response = await fetch(`/dashboard/commands/customizations/${guildId}/commands`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }); + + if (!response.ok) { + throw new Error('Failed to save command customization'); + } + + // Update UI + customizationDiv.style.display = 'none'; + editButton.style.display = 'inline-block'; + + if (customName) { + nameElement.textContent = `${customName} (${commandName})`; + } else { + nameElement.textContent = commandName; + } + + if (customDescription) { + descriptionElement.textContent = customDescription; + } else { + descriptionElement.textContent = 'No description available'; + } + + showToast('success', 'Success', 'Command customization saved successfully'); + } catch (error) { + console.error('Error saving command customization:', error); + showToast('error', 'Error', 'Failed to save command customization'); + } +} + +/** + * Reset command customization + * @param {string} commandName - Original command name + */ +async function resetCommandCustomization(commandName) { + try { + // Get the current guild ID + const guildId = getCurrentGuildId(); + if (!guildId) { + showToast('error', 'Error', 'No guild selected'); + return; + } + + // Prepare request data + const requestData = { + command_name: commandName, + custom_name: null, + custom_description: null + }; + + // Send request to API + const response = await fetch(`/dashboard/commands/customizations/${guildId}/commands`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }); + + if (!response.ok) { + throw new Error('Failed to reset command customization'); + } + + // Reload command customizations + loadCommandCustomizations(); + + showToast('success', 'Success', 'Command customization reset successfully'); + } catch (error) { + console.error('Error resetting command customization:', error); + showToast('error', 'Error', 'Failed to reset command customization'); + } +} + +/** + * Render group customizations + * @param {Object} groupCustomizations - Group customizations object + */ +function renderGroupCustomizations(groupCustomizations) { + const groupList = document.getElementById('group-list'); + groupList.innerHTML = ''; + + if (Object.keys(groupCustomizations).length === 0) { + groupList.innerHTML = '
No command groups found
'; + return; + } + + // Sort groups alphabetically + const sortedGroups = Object.keys(groupCustomizations).sort(); + + // Create group items + sortedGroups.forEach(groupName => { + const customName = groupCustomizations[groupName]; + const groupItem = createGroupItem(groupName, customName); + groupList.appendChild(groupItem); + }); +} + +/** + * Create a group item element + * @param {string} groupName - Original group name + * @param {string} customName - Custom group name + * @returns {HTMLElement} Group item element + */ +function createGroupItem(groupName, customName) { + // Clone the template + const template = document.getElementById('group-item-template'); + const groupItem = template.content.cloneNode(true).querySelector('.command-item'); + + // Set group name + const nameElement = groupItem.querySelector('.group-name'); + nameElement.textContent = groupName; + if (customName && customName !== groupName) { + nameElement.textContent = `${customName} (${groupName})`; + } + + // Set custom name input value + const customNameInput = groupItem.querySelector('.custom-group-name'); + customNameInput.value = customName || ''; + customNameInput.placeholder = groupName; + + // Add event listeners to buttons + const editButton = groupItem.querySelector('.edit-group-btn'); + const resetButton = groupItem.querySelector('.reset-group-btn'); + const saveButton = groupItem.querySelector('.save-group-btn'); + const cancelButton = groupItem.querySelector('.cancel-group-btn'); + const customizationDiv = groupItem.querySelector('.group-customization'); + + editButton.addEventListener('click', () => { + customizationDiv.style.display = 'block'; + editButton.style.display = 'none'; + }); + + resetButton.addEventListener('click', () => { + resetGroupCustomization(groupName); + }); + + saveButton.addEventListener('click', () => { + saveGroupCustomization( + groupName, + customNameInput.value, + customizationDiv, + editButton, + nameElement + ); + }); + + cancelButton.addEventListener('click', () => { + customizationDiv.style.display = 'none'; + editButton.style.display = 'inline-block'; + + // Reset input value + customNameInput.value = customName || ''; + }); + + return groupItem; +} + +/** + * Save group customization + * @param {string} groupName - Original group name + * @param {string} customName - Custom group name + * @param {HTMLElement} customizationDiv - Group customization div + * @param {HTMLElement} editButton - Edit button + * @param {HTMLElement} nameElement - Group name element + */ +async function saveGroupCustomization( + groupName, + customName, + customizationDiv, + editButton, + nameElement +) { + try { + // Validate custom name format if provided + if (customName && (!/^[a-z][a-z0-9_]*$/.test(customName) || customName.length > 32)) { + showToast('error', 'Error', 'Custom group names must be lowercase, start with a letter, and contain only letters, numbers, and underscores (max 32 characters)'); + return; + } + + // Get the current guild ID + const guildId = getCurrentGuildId(); + if (!guildId) { + showToast('error', 'Error', 'No guild selected'); + return; + } + + // Prepare request data + const requestData = { + group_name: groupName, + custom_name: customName || null + }; + + // Send request to API + const response = await fetch(`/dashboard/commands/customizations/${guildId}/groups`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }); + + if (!response.ok) { + throw new Error('Failed to save group customization'); + } + + // Update UI + customizationDiv.style.display = 'none'; + editButton.style.display = 'inline-block'; + + if (customName) { + nameElement.textContent = `${customName} (${groupName})`; + } else { + nameElement.textContent = groupName; + } + + showToast('success', 'Success', 'Group customization saved successfully'); + } catch (error) { + console.error('Error saving group customization:', error); + showToast('error', 'Error', 'Failed to save group customization'); + } +} + +/** + * Reset group customization + * @param {string} groupName - Original group name + */ +async function resetGroupCustomization(groupName) { + try { + // Get the current guild ID + const guildId = getCurrentGuildId(); + if (!guildId) { + showToast('error', 'Error', 'No guild selected'); + return; + } + + // Prepare request data + const requestData = { + group_name: groupName, + custom_name: null + }; + + // Send request to API + const response = await fetch(`/dashboard/commands/customizations/${guildId}/groups`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }); + + if (!response.ok) { + throw new Error('Failed to reset group customization'); + } + + // Reload command customizations + loadCommandCustomizations(); + + showToast('success', 'Success', 'Group customization reset successfully'); + } catch (error) { + console.error('Error resetting group customization:', error); + showToast('error', 'Error', 'Failed to reset group customization'); + } +} + +/** + * Render command aliases + * @param {Object} commandAliases - Command aliases object + */ +function renderCommandAliases(commandAliases) { + const aliasList = document.getElementById('alias-list'); + aliasList.innerHTML = ''; + + if (Object.keys(commandAliases).length === 0) { + aliasList.innerHTML = '
No command aliases found
'; + return; + } + + // Sort commands alphabetically + const sortedCommands = Object.keys(commandAliases).sort(); + + // Create alias items + sortedCommands.forEach(commandName => { + const aliases = commandAliases[commandName]; + if (aliases && aliases.length > 0) { + const aliasItem = createAliasItem(commandName, aliases); + aliasList.appendChild(aliasItem); + } + }); +} + +/** + * Create an alias item element + * @param {string} commandName - Original command name + * @param {Array} aliases - Command aliases + * @returns {HTMLElement} Alias item element + */ +function createAliasItem(commandName, aliases) { + // Clone the template + const template = document.getElementById('alias-item-template'); + const aliasItem = template.content.cloneNode(true).querySelector('.alias-item'); + + // Set command name + const nameElement = aliasItem.querySelector('.command-name'); + nameElement.textContent = commandName; + + // Add alias tags + const aliasTagsList = aliasItem.querySelector('.alias-tags'); + aliases.forEach(alias => { + const aliasTag = createAliasTag(commandName, alias); + aliasTagsList.appendChild(aliasTag); + }); + + return aliasItem; +} + +/** + * Create an alias tag element + * @param {string} commandName - Original command name + * @param {string} alias - Command alias + * @returns {HTMLElement} Alias tag element + */ +function createAliasTag(commandName, alias) { + // Clone the template + const template = document.getElementById('alias-tag-template'); + const aliasTag = template.content.cloneNode(true).querySelector('.alias-tag'); + + // Set alias name + const nameElement = aliasTag.querySelector('.alias-name'); + nameElement.textContent = alias; + + // Add event listener to remove button + const removeButton = aliasTag.querySelector('.remove-alias-btn'); + removeButton.addEventListener('click', () => { + removeAlias(commandName, alias); + }); + + return aliasTag; +} + +/** + * Populate command select for aliases + * @param {Array} commands - Command names + */ +function populateCommandSelect(commands) { + const commandSelect = document.getElementById('alias-command-select'); + commandSelect.innerHTML = ''; + + // Sort commands alphabetically + const sortedCommands = commands.sort(); + + // Add command options + sortedCommands.forEach(commandName => { + const option = document.createElement('option'); + option.value = commandName; + option.textContent = commandName; + commandSelect.appendChild(option); + }); +} + +/** + * Add a command alias + */ +async function addAlias() { + try { + // Get input values + const commandSelect = document.getElementById('alias-command-select'); + const aliasInput = document.getElementById('alias-name-input'); + const feedbackElement = document.getElementById('alias-feedback'); + + const commandName = commandSelect.value; + const aliasName = aliasInput.value.trim(); + + // Validate inputs + if (!commandName) { + feedbackElement.textContent = 'Please select a command'; + feedbackElement.className = 'mt-2 text-danger'; + return; + } + + if (!aliasName) { + feedbackElement.textContent = 'Please enter an alias name'; + feedbackElement.className = 'mt-2 text-danger'; + return; + } + + // Validate alias format + if (!/^[a-z][a-z0-9_]*$/.test(aliasName) || aliasName.length > 32) { + feedbackElement.textContent = 'Alias names must be lowercase, start with a letter, and contain only letters, numbers, and underscores (max 32 characters)'; + feedbackElement.className = 'mt-2 text-danger'; + return; + } + + // Get the current guild ID + const guildId = getCurrentGuildId(); + if (!guildId) { + feedbackElement.textContent = 'No guild selected'; + feedbackElement.className = 'mt-2 text-danger'; + return; + } + + // Prepare request data + const requestData = { + command_name: commandName, + alias_name: aliasName + }; + + // Send request to API + const response = await fetch(`/dashboard/commands/customizations/${guildId}/aliases`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }); + + if (!response.ok) { + throw new Error('Failed to add command alias'); + } + + // Clear input + aliasInput.value = ''; + + // Show success message + feedbackElement.textContent = 'Alias added successfully'; + feedbackElement.className = 'mt-2 text-success'; + + // Reload command customizations + loadCommandCustomizations(); + + showToast('success', 'Success', 'Command alias added successfully'); + } catch (error) { + console.error('Error adding command alias:', error); + + const feedbackElement = document.getElementById('alias-feedback'); + feedbackElement.textContent = 'Failed to add command alias'; + feedbackElement.className = 'mt-2 text-danger'; + + showToast('error', 'Error', 'Failed to add command alias'); + } +} + +/** + * Remove a command alias + * @param {string} commandName - Original command name + * @param {string} aliasName - Command alias + */ +async function removeAlias(commandName, aliasName) { + try { + // Get the current guild ID + const guildId = getCurrentGuildId(); + if (!guildId) { + showToast('error', 'Error', 'No guild selected'); + return; + } + + // Prepare request data + const requestData = { + command_name: commandName, + alias_name: aliasName + }; + + // Send request to API + const response = await fetch(`/dashboard/commands/customizations/${guildId}/aliases`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }); + + if (!response.ok) { + throw new Error('Failed to remove command alias'); + } + + // Reload command customizations + loadCommandCustomizations(); + + showToast('success', 'Success', 'Command alias removed successfully'); + } catch (error) { + console.error('Error removing command alias:', error); + showToast('error', 'Error', 'Failed to remove command alias'); + } +} + +/** + * Sync commands to Discord + */ +async function syncCommands() { + try { + // Get the current guild ID + const guildId = getCurrentGuildId(); + if (!guildId) { + showToast('error', 'Error', 'No guild selected'); + return; + } + + // Show feedback + const feedbackElement = document.getElementById('sync-feedback'); + feedbackElement.textContent = 'Syncing commands...'; + feedbackElement.className = 'mt-2 text-info'; + + // Send request to API + const response = await fetch(`/dashboard/commands/customizations/${guildId}/sync`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error('Failed to sync commands'); + } + + // Show success message + feedbackElement.textContent = 'Commands synced successfully'; + feedbackElement.className = 'mt-2 text-success'; + + showToast('success', 'Success', 'Commands synced successfully'); + } catch (error) { + console.error('Error syncing commands:', error); + + // Show error message + const feedbackElement = document.getElementById('sync-feedback'); + feedbackElement.textContent = 'Failed to sync commands'; + feedbackElement.className = 'mt-2 text-danger'; + + showToast('error', 'Error', 'Failed to sync commands'); + } +} + +/** + * Filter commands by search query + */ +function filterCommands() { + const searchQuery = document.getElementById('command-search').value.toLowerCase(); + const commandItems = document.querySelectorAll('#command-list .command-item'); + + commandItems.forEach(item => { + const commandName = item.dataset.commandName; + if (commandName.includes(searchQuery)) { + item.style.display = 'block'; + } else { + item.style.display = 'none'; + } + }); +} diff --git a/api_service/dashboard_web/js/main.js b/api_service/dashboard_web/js/main.js index dc65c57..9fb3a2c 100644 --- a/api_service/dashboard_web/js/main.js +++ b/api_service/dashboard_web/js/main.js @@ -312,6 +312,12 @@ function showSection(sectionId) { if (sectionId === 'ai-settings' && typeof loadAiSettings === 'function' && typeof aiSettingsLoaded !== 'undefined' && !aiSettingsLoaded) { loadAiSettings(); } + + // Load cog management if needed + if (sectionId === 'cog-management' && typeof loadGuildsForCogManagement === 'function' && typeof cogManagementLoaded !== 'undefined' && !cogManagementLoaded) { + loadGuildsForCogManagement(); + cogManagementLoaded = true; + } } /** diff --git a/api_service/dashboard_web/js/theme-settings.js b/api_service/dashboard_web/js/theme-settings.js new file mode 100644 index 0000000..2e6fd1b --- /dev/null +++ b/api_service/dashboard_web/js/theme-settings.js @@ -0,0 +1,325 @@ +/** + * Theme Settings JavaScript + * Handles theme customization for the dashboard + */ + +// Initialize theme settings when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + initThemeSettings(); +}); + +/** + * Initialize theme settings + */ +function initThemeSettings() { + // Get theme mode radio buttons + const themeModeRadios = document.querySelectorAll('input[name="theme_mode"]'); + + // Add event listeners to theme mode radio buttons + themeModeRadios.forEach(radio => { + radio.addEventListener('change', () => { + const customThemeSettings = document.getElementById('custom-theme-settings'); + if (radio.value === 'custom') { + customThemeSettings.style.display = 'block'; + } else { + customThemeSettings.style.display = 'none'; + } + updateThemePreview(); + }); + }); + + // Add event listeners to color pickers + const colorInputs = document.querySelectorAll('input[type="color"]'); + colorInputs.forEach(input => { + // Get the corresponding text input + const textInput = document.getElementById(`${input.id}-text`); + + // Update text input when color input changes + input.addEventListener('input', () => { + textInput.value = input.value; + updateThemePreview(); + }); + + // Update color input when text input changes + textInput.addEventListener('input', () => { + // Validate hex color format + if (/^#[0-9A-F]{6}$/i.test(textInput.value)) { + input.value = textInput.value; + updateThemePreview(); + } + }); + }); + + // Add event listener to font family select + const fontFamilySelect = document.getElementById('font-family'); + fontFamilySelect.addEventListener('change', updateThemePreview); + + // Add event listener to custom CSS textarea + const customCssTextarea = document.getElementById('custom-css'); + customCssTextarea.addEventListener('input', updateThemePreview); + + // Add event listener to save button + const saveButton = document.getElementById('save-theme-settings-button'); + saveButton.addEventListener('click', saveThemeSettings); + + // Add event listener to reset button + const resetButton = document.getElementById('reset-theme-settings-button'); + resetButton.addEventListener('click', resetThemeSettings); + + // Load theme settings from API + loadThemeSettings(); +} + +/** + * Load theme settings from API + */ +async function loadThemeSettings() { + try { + // Show loading spinner + const themeSettingsForm = document.getElementById('theme-settings-form'); + themeSettingsForm.innerHTML = '
'; + + // Fetch theme settings from API + const response = await fetch('/dashboard/api/settings'); + if (!response.ok) { + throw new Error('Failed to load theme settings'); + } + + const data = await response.json(); + + // Restore the form + themeSettingsForm.innerHTML = document.getElementById('theme-settings-template').innerHTML; + + // Initialize event listeners again + initThemeSettings(); + + // Set theme settings values + if (data && data.theme) { + const theme = data.theme; + + // Set theme mode + const themeModeRadio = document.querySelector(`input[name="theme_mode"][value="${theme.theme_mode}"]`); + if (themeModeRadio) { + themeModeRadio.checked = true; + + // Show/hide custom theme settings + const customThemeSettings = document.getElementById('custom-theme-settings'); + if (theme.theme_mode === 'custom') { + customThemeSettings.style.display = 'block'; + } else { + customThemeSettings.style.display = 'none'; + } + } + + // Set color values + if (theme.primary_color) { + document.getElementById('primary-color').value = theme.primary_color; + document.getElementById('primary-color-text').value = theme.primary_color; + } + + if (theme.secondary_color) { + document.getElementById('secondary-color').value = theme.secondary_color; + document.getElementById('secondary-color-text').value = theme.secondary_color; + } + + if (theme.accent_color) { + document.getElementById('accent-color').value = theme.accent_color; + document.getElementById('accent-color-text').value = theme.accent_color; + } + + // Set font family + if (theme.font_family) { + document.getElementById('font-family').value = theme.font_family; + } + + // Set custom CSS + if (theme.custom_css) { + document.getElementById('custom-css').value = theme.custom_css; + } + + // Update preview + updateThemePreview(); + } + } catch (error) { + console.error('Error loading theme settings:', error); + showToast('error', 'Error', 'Failed to load theme settings'); + } +} + +/** + * Update theme preview + */ +function updateThemePreview() { + const themeMode = document.querySelector('input[name="theme_mode"]:checked').value; + const primaryColor = document.getElementById('primary-color').value; + const secondaryColor = document.getElementById('secondary-color').value; + const accentColor = document.getElementById('accent-color').value; + const fontFamily = document.getElementById('font-family').value; + const customCss = document.getElementById('custom-css').value; + + const preview = document.getElementById('theme-preview'); + + // Apply theme mode + if (themeMode === 'dark') { + preview.classList.add('dark-mode'); + preview.classList.remove('custom-mode'); + } else if (themeMode === 'light') { + preview.classList.remove('dark-mode'); + preview.classList.remove('custom-mode'); + } else if (themeMode === 'custom') { + preview.classList.remove('dark-mode'); + preview.classList.add('custom-mode'); + + // Apply custom colors + preview.style.setProperty('--primary-color', primaryColor); + preview.style.setProperty('--secondary-color', secondaryColor); + preview.style.setProperty('--accent-color', accentColor); + preview.style.setProperty('--font-family', fontFamily); + + // Apply custom CSS + const customStyleElement = document.getElementById('custom-theme-style'); + if (customStyleElement) { + customStyleElement.textContent = customCss; + } else { + const style = document.createElement('style'); + style.id = 'custom-theme-style'; + style.textContent = customCss; + document.head.appendChild(style); + } + } +} + +/** + * Save theme settings + */ +async function saveThemeSettings() { + try { + const saveButton = document.getElementById('save-theme-settings-button'); + saveButton.classList.add('btn-loading'); + + const themeMode = document.querySelector('input[name="theme_mode"]:checked').value; + const primaryColor = document.getElementById('primary-color').value; + const secondaryColor = document.getElementById('secondary-color').value; + const accentColor = document.getElementById('accent-color').value; + const fontFamily = document.getElementById('font-family').value; + const customCss = document.getElementById('custom-css').value; + + // Create theme settings object + const themeSettings = { + theme_mode: themeMode, + primary_color: primaryColor, + secondary_color: secondaryColor, + accent_color: accentColor, + font_family: fontFamily, + custom_css: customCss + }; + + // Send theme settings to API + const response = await fetch('/dashboard/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + settings: { + theme: themeSettings + } + }) + }); + + if (!response.ok) { + throw new Error('Failed to save theme settings'); + } + + // Show success message + const feedbackElement = document.getElementById('theme-settings-feedback'); + feedbackElement.textContent = 'Theme settings saved successfully!'; + feedbackElement.classList.add('text-success'); + + // Apply theme to the entire dashboard + applyThemeToDocument(themeSettings); + + // Show toast notification + showToast('success', 'Success', 'Theme settings saved successfully!'); + } catch (error) { + console.error('Error saving theme settings:', error); + + // Show error message + const feedbackElement = document.getElementById('theme-settings-feedback'); + feedbackElement.textContent = 'Failed to save theme settings. Please try again.'; + feedbackElement.classList.add('text-danger'); + + // Show toast notification + showToast('error', 'Error', 'Failed to save theme settings'); + } finally { + // Remove loading state from button + const saveButton = document.getElementById('save-theme-settings-button'); + saveButton.classList.remove('btn-loading'); + } +} + +/** + * Reset theme settings to defaults + */ +function resetThemeSettings() { + // Set theme mode to light + document.getElementById('theme-mode-light').checked = true; + + // Hide custom theme settings + document.getElementById('custom-theme-settings').style.display = 'none'; + + // Reset color values + document.getElementById('primary-color').value = '#5865F2'; + document.getElementById('primary-color-text').value = '#5865F2'; + document.getElementById('secondary-color').value = '#2D3748'; + document.getElementById('secondary-color-text').value = '#2D3748'; + document.getElementById('accent-color').value = '#7289DA'; + document.getElementById('accent-color-text').value = '#7289DA'; + + // Reset font family + document.getElementById('font-family').value = 'Inter, sans-serif'; + + // Reset custom CSS + document.getElementById('custom-css').value = ''; + + // Update preview + updateThemePreview(); + + // Show toast notification + showToast('info', 'Reset', 'Theme settings reset to defaults'); +} + +/** + * Apply theme to the entire document + * @param {Object} theme - Theme settings object + */ +function applyThemeToDocument(theme) { + // Apply theme mode + if (theme.theme_mode === 'dark') { + document.body.classList.add('dark-mode'); + document.body.classList.remove('custom-mode'); + } else if (theme.theme_mode === 'light') { + document.body.classList.remove('dark-mode'); + document.body.classList.remove('custom-mode'); + } else if (theme.theme_mode === 'custom') { + document.body.classList.remove('dark-mode'); + document.body.classList.add('custom-mode'); + + // Apply custom colors + document.documentElement.style.setProperty('--primary-color', theme.primary_color); + document.documentElement.style.setProperty('--secondary-color', theme.secondary_color); + document.documentElement.style.setProperty('--accent-color', theme.accent_color); + document.documentElement.style.setProperty('--font-family', theme.font_family); + + // Apply custom CSS + const customStyleElement = document.getElementById('global-custom-theme-style'); + if (customStyleElement) { + customStyleElement.textContent = theme.custom_css; + } else { + const style = document.createElement('style'); + style.id = 'global-custom-theme-style'; + style.textContent = theme.custom_css; + document.head.appendChild(style); + } + } +} diff --git a/api_service/dashboard_web/theme-settings.html b/api_service/dashboard_web/theme-settings.html new file mode 100644 index 0000000..38fc9b5 --- /dev/null +++ b/api_service/dashboard_web/theme-settings.html @@ -0,0 +1,107 @@ + + diff --git a/command_customization.py b/command_customization.py index 9e9eb17..a5bfc6d 100644 --- a/command_customization.py +++ b/command_customization.py @@ -54,7 +54,7 @@ class GuildCommandSyncer: async def prepare_guild_commands(self, guild_id: int) -> List[app_commands.Command]: """ - Prepare guild-specific commands with customized names. + Prepare guild-specific commands with customized names and descriptions. Returns a list of commands with guild-specific customizations applied. """ # Get all global commands @@ -69,12 +69,18 @@ class GuildCommandSyncer: if not customizations: return global_commands # No customizations, use global commands - # Create guild-specific commands with custom names + # Create guild-specific commands with custom names and descriptions guild_commands = [] for cmd in global_commands: + # Set guild_id attribute for use in _create_custom_command + setattr(cmd, 'guild_id', guild_id) + if cmd.name in customizations: - # Create a copy of the command with the custom name - custom_name = customizations[cmd.name] + # Get the custom name + custom_data = customizations[cmd.name] + custom_name = custom_data.get('name', cmd.name) + + # Create a copy of the command with the custom name and description custom_cmd = self._create_custom_command(cmd, custom_name) guild_commands.append(custom_cmd) else: @@ -91,14 +97,23 @@ class GuildCommandSyncer: def _create_custom_command(self, original_cmd: app_commands.Command, custom_name: str) -> app_commands.Command: """ - Create a copy of a command with a custom name. + Create a copy of a command with a custom name and description. This is a simplified version - in practice, you'd need to handle all command attributes. """ - # For simplicity, we're just creating a basic copy with the custom name + # Get custom description if available + custom_description = None + if hasattr(original_cmd, 'guild_id') and original_cmd.guild_id: + # This is a guild-specific command, get the custom description + custom_description = asyncio.run_coroutine_threadsafe( + settings_manager.get_custom_command_description(original_cmd.guild_id, original_cmd.name), + asyncio.get_event_loop() + ).result() + + # For simplicity, we're just creating a basic copy with the custom name and description # In a real implementation, you'd need to handle all command attributes and options custom_cmd = app_commands.Command( name=custom_name, - description=original_cmd.description, + description=custom_description or original_cmd.description, callback=original_cmd.callback ) @@ -117,6 +132,11 @@ class GuildCommandSyncer: # Prepare guild-specific commands guild_commands = await self.prepare_guild_commands(guild.id) + # Set the commands for this guild + self.bot.tree.clear_commands(guild=guild) + for cmd in guild_commands: + self.bot.tree.add_command(cmd, guild=guild) + # Sync commands with Discord synced = await self.bot.tree.sync(guild=guild) diff --git a/main.py b/main.py index 9c87e4b..a84be57 100644 --- a/main.py +++ b/main.py @@ -201,6 +201,9 @@ async def on_command_error(ctx, error): elif isinstance(error, CommandPermissionError): await ctx.send(str(error), ephemeral=True) # Send the error message from the exception log.warning(f"Command '{ctx.command.qualified_name}' blocked for user {ctx.author.id} in guild {ctx.guild.id}: {error}") + elif isinstance(error, CommandDisabledError): + await ctx.send(str(error), ephemeral=True) # Send the error message from the exception + log.warning(f"Command '{ctx.command.qualified_name}' blocked for user {ctx.author.id} in guild {ctx.guild.id}: {error}") else: # Pass other errors to the original handler await handle_error(ctx, error) @@ -232,6 +235,12 @@ class CommandPermissionError(commands.CheckFailure): self.command_name = command_name super().__init__(f"You do not have the required role to use the command `{command_name}`.") +class CommandDisabledError(commands.CheckFailure): + """Custom exception for disabled commands.""" + def __init__(self, command_name): + self.command_name = command_name + super().__init__(f"The command `{command_name}` is disabled in this server.") + @bot.before_invoke async def global_command_checks(ctx: commands.Context): """Global check run before any command invocation.""" @@ -268,7 +277,14 @@ async def global_command_checks(ctx: commands.Context): log.warning(f"Command '{command_name}' blocked in guild {guild_id}: Cog '{cog_name}' is disabled.") raise CogDisabledError(cog_name) - # 2. Check command permissions based on roles + # 2. Check if the Command is enabled + # This only applies if the command has been explicitly disabled + is_cmd_enabled = await settings_manager.is_command_enabled(guild_id, command_name, default_enabled=True) + if not is_cmd_enabled: + log.warning(f"Command '{command_name}' blocked in guild {guild_id}: Command is disabled.") + raise CommandDisabledError(command_name) + + # 3. Check command permissions based on roles # This check only applies if specific permissions HAVE been set for this command. # If no permissions are set in the DB, check_command_permission returns True. has_perm = await settings_manager.check_command_permission(guild_id, command_name, member_roles_ids) @@ -276,7 +292,7 @@ async def global_command_checks(ctx: commands.Context): log.warning(f"Command '{command_name}' blocked for user {ctx.author.id} in guild {guild_id}: Insufficient role permissions.") raise CommandPermissionError(command_name) - # If both checks pass, the command proceeds. + # If all checks pass, the command proceeds. log.debug(f"Command '{command_name}' passed global checks for user {ctx.author.id} in guild {guild_id}.") diff --git a/settings_manager.py b/settings_manager.py index 137d0f7..0507df8 100644 --- a/settings_manager.py +++ b/settings_manager.py @@ -3,6 +3,7 @@ import redis.asyncio as redis import os import logging from dotenv import load_dotenv +from typing import Dict # Load environment variables load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '.env')) @@ -138,6 +139,17 @@ async def initialize_database(): ); """) + # Enabled Commands table - Stores the explicit enabled/disabled state for individual commands + await conn.execute(""" + CREATE TABLE IF NOT EXISTS enabled_commands ( + guild_id BIGINT NOT NULL, + command_name TEXT NOT NULL, + enabled BOOLEAN NOT NULL, + PRIMARY KEY (guild_id, command_name), + FOREIGN KEY (guild_id) REFERENCES guilds(guild_id) ON DELETE CASCADE + ); + """) + # Command Permissions table (simple role-based for now) await conn.execute(""" CREATE TABLE IF NOT EXISTS command_permissions ( @@ -149,12 +161,13 @@ async def initialize_database(): ); """) - # Command Customization table - Stores guild-specific command names + # Command Customization table - Stores guild-specific command names and descriptions await conn.execute(""" CREATE TABLE IF NOT EXISTS command_customization ( guild_id BIGINT NOT NULL, original_command_name TEXT NOT NULL, custom_command_name TEXT NOT NULL, + custom_command_description TEXT, PRIMARY KEY (guild_id, original_command_name), FOREIGN KEY (guild_id) REFERENCES guilds(guild_id) ON DELETE CASCADE ); @@ -428,6 +441,119 @@ async def set_cog_enabled(guild_id: int, cog_name: str, enabled: bool): log.exception(f"Failed to invalidate Redis cache for cog enabled status '{cog_name}' (Guild: {guild_id}): {redis_err}") return False + +async def is_command_enabled(guild_id: int, command_name: str, default_enabled: bool = True) -> bool: + """Checks if a command is enabled for a guild, checking cache first. + Uses default_enabled if no specific setting is found.""" + if not pg_pool or not redis_pool: + log.warning(f"Pools not initialized, returning default for command '{command_name}'.") + return default_enabled + + cache_key = _get_redis_key(guild_id, "cmd_enabled", command_name) + try: + cached_value = await redis_pool.get(cache_key) + if cached_value is not None: + log.debug(f"Cache hit for command enabled status '{command_name}' (Guild: {guild_id})") + return cached_value == "True" # Redis stores strings + except Exception as e: + log.exception(f"Redis error getting command enabled status for '{command_name}' (Guild: {guild_id}): {e}") + + log.debug(f"Cache miss for command enabled status '{command_name}' (Guild: {guild_id})") + db_enabled_status = None + try: + async with pg_pool.acquire() as conn: + db_enabled_status = await conn.fetchval( + "SELECT enabled FROM enabled_commands WHERE guild_id = $1 AND command_name = $2", + guild_id, command_name + ) + except Exception as e: + log.exception(f"Database error getting command enabled status for '{command_name}' (Guild: {guild_id}): {e}") + # Fallback to default on DB error after cache miss + return default_enabled + + final_status = db_enabled_status if db_enabled_status is not None else default_enabled + + # Cache the result (True or False) + try: + await redis_pool.set(cache_key, str(final_status), ex=3600) # Cache for 1 hour + except Exception as e: + log.exception(f"Redis error setting cache for command enabled status '{command_name}' (Guild: {guild_id}): {e}") + + return final_status + + +async def set_command_enabled(guild_id: int, command_name: str, enabled: bool): + """Sets the enabled status for a command in a guild and updates the cache.""" + if not pg_pool or not redis_pool: + log.error(f"Pools not initialized, cannot set command enabled status for '{command_name}'.") + return False + + cache_key = _get_redis_key(guild_id, "cmd_enabled", command_name) + try: + async with pg_pool.acquire() as conn: + # Ensure guild exists + await conn.execute("INSERT INTO guilds (guild_id) VALUES ($1) ON CONFLICT (guild_id) DO NOTHING;", guild_id) + # Upsert the enabled status + await conn.execute( + """ + INSERT INTO enabled_commands (guild_id, command_name, enabled) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, command_name) DO UPDATE SET enabled = $3; + """, + guild_id, command_name, enabled + ) + + # Update cache + await redis_pool.set(cache_key, str(enabled), ex=3600) + log.info(f"Set command '{command_name}' enabled status to {enabled} for guild {guild_id}") + return True + except Exception as e: + log.exception(f"Database or Redis error setting command enabled status for '{command_name}' in guild {guild_id}: {e}") + # Attempt to invalidate cache on error + try: + await redis_pool.delete(cache_key) + except Exception as redis_err: + log.exception(f"Failed to invalidate Redis cache for command enabled status '{command_name}' (Guild: {guild_id}): {redis_err}") + return False + + +async def get_all_enabled_commands(guild_id: int) -> Dict[str, bool]: + """Gets all command enabled statuses for a guild. + Returns a dictionary of command_name -> enabled status.""" + if not pg_pool: + log.error(f"Database pool not initialized, cannot get command enabled statuses for guild {guild_id}.") + return {} + + try: + async with pg_pool.acquire() as conn: + records = await conn.fetch( + "SELECT command_name, enabled FROM enabled_commands WHERE guild_id = $1", + guild_id + ) + return {record['command_name']: record['enabled'] for record in records} + except Exception as e: + log.exception(f"Database error getting command enabled statuses for guild {guild_id}: {e}") + return {} + + +async def get_all_enabled_cogs(guild_id: int) -> Dict[str, bool]: + """Gets all cog enabled statuses for a guild. + Returns a dictionary of cog_name -> enabled status.""" + if not pg_pool: + log.error(f"Database pool not initialized, cannot get cog enabled statuses for guild {guild_id}.") + return {} + + try: + async with pg_pool.acquire() as conn: + records = await conn.fetch( + "SELECT cog_name, enabled FROM enabled_cogs WHERE guild_id = $1", + guild_id + ) + return {record['cog_name']: record['enabled'] for record in records} + except Exception as e: + log.exception(f"Database error getting cog enabled statuses for guild {guild_id}: {e}") + return {} + # --- Command Permission Functions --- async def add_command_permission(guild_id: int, command_name: str, role_id: int) -> bool: @@ -680,6 +806,39 @@ async def get_custom_command_name(guild_id: int, original_command_name: str) -> return custom_name +async def get_custom_command_description(guild_id: int, original_command_name: str) -> str | None: + """Gets the custom command description for a guild, checking cache first. + Returns None if no custom description is set.""" + if not pg_pool or not redis_pool: + log.warning(f"Pools not initialized, returning None for custom command description '{original_command_name}'.") + return None + + cache_key = _get_redis_key(guild_id, "cmd_desc", original_command_name) + try: + cached_value = await redis_pool.get(cache_key) + if cached_value is not None: + log.debug(f"Cache hit for custom command description '{original_command_name}' (Guild: {guild_id})") + return None if cached_value == "__NONE__" else cached_value + except Exception as e: + log.exception(f"Redis error getting custom command description for '{original_command_name}' (Guild: {guild_id}): {e}") + + log.debug(f"Cache miss for custom command description '{original_command_name}' (Guild: {guild_id})") + async with pg_pool.acquire() as conn: + custom_desc = await conn.fetchval( + "SELECT custom_command_description FROM command_customization WHERE guild_id = $1 AND original_command_name = $2", + guild_id, original_command_name + ) + + # Cache the result (even if None) + try: + value_to_cache = custom_desc if custom_desc is not None else "__NONE__" + await redis_pool.set(cache_key, value_to_cache, ex=3600) # Cache for 1 hour + except Exception as e: + log.exception(f"Redis error setting cache for custom command description '{original_command_name}' (Guild: {guild_id}): {e}") + + return custom_desc + + async def set_custom_command_name(guild_id: int, original_command_name: str, custom_command_name: str | None) -> bool: """Sets a custom command name for a guild and updates the cache. Setting custom_command_name to None removes the customization.""" @@ -727,6 +886,74 @@ async def set_custom_command_name(guild_id: int, original_command_name: str, cus return False +async def set_custom_command_description(guild_id: int, original_command_name: str, custom_command_description: str | None) -> bool: + """Sets a custom command description for a guild and updates the cache. + Setting custom_command_description to None removes the description.""" + if not pg_pool or not redis_pool: + log.error(f"Pools not initialized, cannot set custom command description for '{original_command_name}'.") + return False + + cache_key = _get_redis_key(guild_id, "cmd_desc", original_command_name) + try: + async with pg_pool.acquire() as conn: + # Ensure guild exists + await conn.execute("INSERT INTO guilds (guild_id) VALUES ($1) ON CONFLICT (guild_id) DO NOTHING;", guild_id) + + # Check if the command customization exists + exists = await conn.fetchval( + "SELECT 1 FROM command_customization WHERE guild_id = $1 AND original_command_name = $2", + guild_id, original_command_name + ) + + if custom_command_description is not None: + if exists: + # Update the existing record + await conn.execute( + """ + UPDATE command_customization + SET custom_command_description = $3 + WHERE guild_id = $1 AND original_command_name = $2; + """, + guild_id, original_command_name, custom_command_description + ) + else: + # Insert a new record with default custom_command_name (same as original) + await conn.execute( + """ + INSERT INTO command_customization (guild_id, original_command_name, custom_command_name, custom_command_description) + VALUES ($1, $2, $2, $3); + """, + guild_id, original_command_name, custom_command_description + ) + # Update cache + await redis_pool.set(cache_key, custom_command_description, ex=3600) + log.info(f"Set custom command description for '{original_command_name}' for guild {guild_id}") + else: + if exists: + # Update the existing record to remove the description + await conn.execute( + """ + UPDATE command_customization + SET custom_command_description = NULL + WHERE guild_id = $1 AND original_command_name = $2; + """, + guild_id, original_command_name + ) + # Update cache to indicate no description + await redis_pool.set(cache_key, "__NONE__", ex=3600) + log.info(f"Removed custom command description for '{original_command_name}' for guild {guild_id}") + + return True + except Exception as e: + log.exception(f"Database or Redis error setting custom command description for '{original_command_name}' in guild {guild_id}: {e}") + # Attempt to invalidate cache on error + try: + await redis_pool.delete(cache_key) + except Exception as redis_err: + log.exception(f"Failed to invalidate Redis cache for custom command description '{original_command_name}' (Guild: {guild_id}): {redis_err}") + return False + + async def get_custom_group_name(guild_id: int, original_group_name: str) -> str | None: """Gets the custom command group name for a guild, checking cache first. Returns None if no custom name is set.""" @@ -923,19 +1150,26 @@ async def get_command_aliases(guild_id: int, original_command_name: str) -> list return None # Indicate error -async def get_all_command_customizations(guild_id: int) -> dict[str, str] | None: +async def get_all_command_customizations(guild_id: int) -> dict[str, dict[str, str]] | None: """Gets all command customizations for a guild. - Returns a dictionary mapping original command names to custom names, or None on error.""" + Returns a dictionary mapping original command names to a dict with 'name' and 'description' keys, + or None on error.""" if not pg_pool: log.error("Pools not initialized, cannot get command customizations.") return None try: async with pg_pool.acquire() as conn: records = await conn.fetch( - "SELECT original_command_name, custom_command_name FROM command_customization WHERE guild_id = $1", + "SELECT original_command_name, custom_command_name, custom_command_description FROM command_customization WHERE guild_id = $1", guild_id ) - customizations = {record['original_command_name']: record['custom_command_name'] for record in records} + customizations = {} + for record in records: + cmd_name = record['original_command_name'] + customizations[cmd_name] = { + 'name': record['custom_command_name'], + 'description': record['custom_command_description'] + } log.debug(f"Fetched {len(customizations)} command customizations for guild {guild_id}.") return customizations except Exception as e: