feat: EveDash v1.0.0 — Self-contained web dashboard for Red-DiscordBot
- Embedded aiohttp server (no external packages needed beyond aiohttp) - Discord-inspired dark theme SPA frontend - Discord OAuth2 authentication with JWT sessions - Full REST API: bot info, guild management, settings, commands, cogs - Real-time WebSocket for live events - Third-party cog SDK: @dashboard_page decorator for easy integration - Guild selector with search, command toggles, cog load/unload/reload - Admin panel: global config, blacklist management - Responsive design, toast notifications, smooth animations - 3,604 lines across 8 files
This commit is contained in:
parent
f87759fecb
commit
20d6bd2c82
9 changed files with 3617 additions and 1 deletions
5
dashboard/__init__.py
Normal file
5
dashboard/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from .dashboard import EveDash
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(EveDash(bot))
|
||||
335
dashboard/dashboard.py
Normal file
335
dashboard/dashboard.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
"""
|
||||
EveDash — A modern, self-contained web dashboard for Red-DiscordBot.
|
||||
|
||||
Inspired by AAA3A's Dashboard, rebuilt from the ground up with:
|
||||
• Embedded aiohttp server (no external packages needed)
|
||||
• Modern SPA frontend with Discord-inspired dark theme
|
||||
• Real-time WebSocket updates
|
||||
• Third-party cog integration SDK
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import typing
|
||||
|
||||
import discord
|
||||
from redbot.core import Config, checks, commands
|
||||
from redbot.core.bot import Red
|
||||
|
||||
from .sdk import ThirdPartyManager
|
||||
from .server import DashboardServer
|
||||
|
||||
log = logging.getLogger("red.evecogs.dashboard")
|
||||
|
||||
|
||||
class EveDash(commands.Cog):
|
||||
"""🖥️ A modern web dashboard for Red-DiscordBot.
|
||||
|
||||
Manage your bot from a beautiful web interface — configure guilds,
|
||||
manage cogs, toggle commands, and more. Third-party cogs can register
|
||||
their own settings pages using the EveDash SDK.
|
||||
|
||||
**Quick Setup:**
|
||||
1. `[p]evedash setup` — guided setup wizard
|
||||
2. Open the dashboard URL in your browser
|
||||
3. Log in with Discord
|
||||
|
||||
**For cog developers:** See `dashboard/sdk.py` for the integration guide.
|
||||
"""
|
||||
|
||||
__author__ = "EveCogs"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
def __init__(self, bot: Red) -> None:
|
||||
self.bot = bot
|
||||
self.config = Config.get_conf(
|
||||
self,
|
||||
identifier=784917823648172,
|
||||
force_registration=True,
|
||||
)
|
||||
self.config.register_global(
|
||||
host="0.0.0.0",
|
||||
port=42356,
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
redirect_uri=None,
|
||||
secret_key=None,
|
||||
blacklist=[],
|
||||
support_url=None,
|
||||
meta_title=None,
|
||||
meta_description=None,
|
||||
)
|
||||
|
||||
self.server: DashboardServer = DashboardServer(self)
|
||||
self.third_parties: ThirdPartyManager = ThirdPartyManager(bot)
|
||||
self._ready = asyncio.Event()
|
||||
|
||||
async def cog_load(self) -> None:
|
||||
# Generate secret key if not set
|
||||
if not await self.config.secret_key():
|
||||
await self.config.secret_key.set(secrets.token_urlsafe(64))
|
||||
|
||||
asyncio.create_task(self._start_server())
|
||||
|
||||
async def _start_server(self) -> None:
|
||||
await self.bot.wait_until_red_ready()
|
||||
|
||||
host = await self.config.host()
|
||||
port = await self.config.port()
|
||||
|
||||
try:
|
||||
await self.server.start(host, port)
|
||||
self._ready.set()
|
||||
self.bot.dispatch("dashboard_cog_add", self)
|
||||
log.info(f"EveDash v{self.__version__} ready on http://{host}:{port}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to start EveDash server: {e}")
|
||||
|
||||
async def cog_unload(self) -> None:
|
||||
self.bot.dispatch("dashboard_cog_remove", self)
|
||||
await self.server.stop()
|
||||
|
||||
# ── Listeners ────────────────────────────────────────────────────────
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_cog_add(self, cog: commands.Cog) -> None:
|
||||
"""Notify newly-loaded cogs that the dashboard is available."""
|
||||
if not self._ready.is_set():
|
||||
return
|
||||
ev = "on_dashboard_cog_add"
|
||||
for listener_name, listener_func in cog.get_listeners():
|
||||
if listener_name == ev:
|
||||
try:
|
||||
self.bot._schedule_event(listener_func, ev, self)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_cog_remove(self, cog: commands.Cog) -> None:
|
||||
"""Clean up when a third-party cog is unloaded."""
|
||||
self.third_parties.remove_third_party(cog)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_command(self, ctx: commands.Context) -> None:
|
||||
self.server._command_count += 1
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message) -> None:
|
||||
if not message.author.bot:
|
||||
self.server._message_count += 1
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_join(self, member: discord.Member) -> None:
|
||||
await self.server.ws_manager.broadcast("member_join", {
|
||||
"guild_id": str(member.guild.id),
|
||||
"user": str(member),
|
||||
})
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_remove(self, member: discord.Member) -> None:
|
||||
await self.server.ws_manager.broadcast("member_remove", {
|
||||
"guild_id": str(member.guild.id),
|
||||
"user": str(member),
|
||||
})
|
||||
|
||||
# ── Commands ─────────────────────────────────────────────────────────
|
||||
|
||||
@commands.group(name="evedash", aliases=["dashboard", "dash"])
|
||||
async def evedash(self, ctx: commands.Context) -> None:
|
||||
"""🖥️ EveDash — Web Dashboard for Red-DiscordBot."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
port = await self.config.port()
|
||||
redirect_uri = await self.config.redirect_uri()
|
||||
if redirect_uri:
|
||||
base_url = redirect_uri.rsplit("/api/", 1)[0]
|
||||
embed = discord.Embed(
|
||||
title="🖥️ EveDash",
|
||||
description=f"Your bot's web dashboard is live!\n\n**[Open Dashboard]({base_url})**",
|
||||
color=discord.Color.blurple(),
|
||||
)
|
||||
embed.add_field(name="Status", value="🟢 Online", inline=True)
|
||||
embed.add_field(name="Port", value=str(port), inline=True)
|
||||
embed.add_field(
|
||||
name="Third Parties",
|
||||
value=str(len(self.third_parties.third_parties)),
|
||||
inline=True,
|
||||
)
|
||||
await ctx.send(embed=embed)
|
||||
else:
|
||||
await ctx.send(
|
||||
"⚠️ EveDash is not configured yet.\n"
|
||||
f"Run `{ctx.prefix}evedash setup` to get started!"
|
||||
)
|
||||
|
||||
@evedash.command(name="setup")
|
||||
@checks.is_owner()
|
||||
async def evedash_setup(self, ctx: commands.Context) -> None:
|
||||
"""🔧 Guided setup wizard for EveDash.
|
||||
|
||||
You'll need:
|
||||
1. A Discord application with OAuth2 configured
|
||||
2. Your Client ID and Client Secret
|
||||
3. A redirect URI (your domain + /api/auth/callback)
|
||||
"""
|
||||
embed = discord.Embed(
|
||||
title="🔧 EveDash Setup Wizard",
|
||||
description=(
|
||||
"Let's configure your dashboard!\n\n"
|
||||
"**Step 1:** Go to the [Discord Developer Portal](https://discord.com/developers/applications)\n"
|
||||
"**Step 2:** Select your bot's application\n"
|
||||
"**Step 3:** Go to **OAuth2** and note your **Client ID** and **Client Secret**\n"
|
||||
"**Step 4:** Add a redirect URI: `http://YOUR_DOMAIN:PORT/api/auth/callback`\n\n"
|
||||
"Ready? Reply with your **Client ID** (just the number)."
|
||||
),
|
||||
color=discord.Color.blurple(),
|
||||
)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
def check(m):
|
||||
return m.author == ctx.author and m.channel == ctx.channel
|
||||
|
||||
# Client ID
|
||||
try:
|
||||
msg = await self.bot.wait_for("message", check=check, timeout=120)
|
||||
client_id = msg.content.strip()
|
||||
await self.config.client_id.set(client_id)
|
||||
await ctx.send("✅ Client ID saved! Now send your **Client Secret**:")
|
||||
except asyncio.TimeoutError:
|
||||
return await ctx.send("⏰ Setup timed out.")
|
||||
|
||||
# Client Secret
|
||||
try:
|
||||
msg = await self.bot.wait_for("message", check=check, timeout=120)
|
||||
client_secret = msg.content.strip()
|
||||
await self.config.client_secret.set(client_secret)
|
||||
try:
|
||||
await msg.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await ctx.send("✅ Client Secret saved (message deleted for safety)! Now send your **Redirect URI**:\n"
|
||||
f"Example: `http://yourdomain.com:{await self.config.port()}/api/auth/callback`")
|
||||
except asyncio.TimeoutError:
|
||||
return await ctx.send("⏰ Setup timed out.")
|
||||
|
||||
# Redirect URI
|
||||
try:
|
||||
msg = await self.bot.wait_for("message", check=check, timeout=120)
|
||||
redirect_uri = msg.content.strip()
|
||||
await self.config.redirect_uri.set(redirect_uri)
|
||||
except asyncio.TimeoutError:
|
||||
return await ctx.send("⏰ Setup timed out.")
|
||||
|
||||
base_url = redirect_uri.rsplit("/api/", 1)[0]
|
||||
embed = discord.Embed(
|
||||
title="🎉 EveDash Setup Complete!",
|
||||
description=(
|
||||
f"Your dashboard is ready!\n\n"
|
||||
f"**🌐 URL:** {base_url}\n"
|
||||
f"**🔌 Port:** {await self.config.port()}\n\n"
|
||||
f"Open the link above and log in with Discord.\n"
|
||||
f"Only bot owners and server admins/managers can access it."
|
||||
),
|
||||
color=discord.Color.green(),
|
||||
)
|
||||
embed.set_footer(text="Tip: Use [p]evedash port <number> to change the port")
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@evedash.command(name="port")
|
||||
@checks.is_owner()
|
||||
async def evedash_port(self, ctx: commands.Context, port: int) -> None:
|
||||
"""Change the dashboard port. Requires a cog reload."""
|
||||
if not 1024 <= port <= 65535:
|
||||
return await ctx.send("❌ Port must be between 1024 and 65535.")
|
||||
await self.config.port.set(port)
|
||||
await ctx.send(f"✅ Port set to **{port}**. Reload the cog (`{ctx.prefix}reload dashboard`) for it to take effect.")
|
||||
|
||||
@evedash.command(name="host")
|
||||
@checks.is_owner()
|
||||
async def evedash_host(self, ctx: commands.Context, host: str) -> None:
|
||||
"""Change the dashboard bind address (default: 0.0.0.0)."""
|
||||
await self.config.host.set(host)
|
||||
await ctx.send(f"✅ Host set to **{host}**. Reload the cog for it to take effect.")
|
||||
|
||||
@evedash.command(name="secret")
|
||||
@checks.is_owner()
|
||||
async def evedash_secret(self, ctx: commands.Context, *, secret: str) -> None:
|
||||
"""Set the Discord OAuth2 client secret."""
|
||||
await self.config.client_secret.set(secret)
|
||||
try:
|
||||
await ctx.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await ctx.send("✅ Client secret updated.")
|
||||
|
||||
@evedash.command(name="clientid")
|
||||
@checks.is_owner()
|
||||
async def evedash_clientid(self, ctx: commands.Context, client_id: str) -> None:
|
||||
"""Set the Discord OAuth2 client ID."""
|
||||
await self.config.client_id.set(client_id)
|
||||
await ctx.send(f"✅ Client ID set to `{client_id}`.")
|
||||
|
||||
@evedash.command(name="redirect")
|
||||
@checks.is_owner()
|
||||
async def evedash_redirect(self, ctx: commands.Context, *, uri: str) -> None:
|
||||
"""Set the OAuth2 redirect URI."""
|
||||
if not uri.endswith("/api/auth/callback"):
|
||||
return await ctx.send("⚠️ Redirect URI must end with `/api/auth/callback`.")
|
||||
await self.config.redirect_uri.set(uri)
|
||||
await ctx.send(f"✅ Redirect URI set to `{uri}`.")
|
||||
|
||||
@evedash.command(name="blacklist")
|
||||
@checks.is_owner()
|
||||
async def evedash_blacklist(self, ctx: commands.Context, user: discord.User) -> None:
|
||||
"""Toggle a user on the dashboard blacklist."""
|
||||
async with self.config.blacklist() as bl:
|
||||
if user.id in bl:
|
||||
bl.remove(user.id)
|
||||
await ctx.send(f"✅ **{user}** removed from dashboard blacklist.")
|
||||
else:
|
||||
bl.append(user.id)
|
||||
await ctx.send(f"✅ **{user}** added to dashboard blacklist.")
|
||||
|
||||
@evedash.command(name="info")
|
||||
async def evedash_info(self, ctx: commands.Context) -> None:
|
||||
"""Show dashboard status and info."""
|
||||
redirect_uri = await self.config.redirect_uri()
|
||||
port = await self.config.port()
|
||||
host = await self.config.host()
|
||||
tp_count = len(self.third_parties.third_parties)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🖥️ EveDash Info",
|
||||
color=discord.Color.blurple(),
|
||||
)
|
||||
embed.add_field(
|
||||
name="Status",
|
||||
value="🟢 Online" if self._ready.is_set() else "🔴 Offline",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name="Host", value=f"`{host}:{port}`", inline=True)
|
||||
embed.add_field(name="Third Parties", value=str(tp_count), inline=True)
|
||||
embed.add_field(name="Version", value=f"v{self.__version__}", inline=True)
|
||||
embed.add_field(
|
||||
name="Messages Tracked",
|
||||
value=f"{self.server._message_count:,}",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Commands Tracked",
|
||||
value=f"{self.server._command_count:,}",
|
||||
inline=True,
|
||||
)
|
||||
|
||||
if redirect_uri:
|
||||
base_url = redirect_uri.rsplit("/api/", 1)[0]
|
||||
embed.add_field(name="URL", value=base_url, inline=False)
|
||||
|
||||
if tp_count:
|
||||
tp_names = ", ".join(self.third_parties.third_parties.keys())
|
||||
embed.add_field(name="Registered Cogs", value=tp_names, inline=False)
|
||||
|
||||
embed.set_footer(text="EveDash — Web Dashboard for Red-DiscordBot")
|
||||
await ctx.send(embed=embed)
|
||||
12
dashboard/info.json
Normal file
12
dashboard/info.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"author": ["everestmcarthur"],
|
||||
"install_msg": "**EveDash** — Web Dashboard for Red-DiscordBot\n\nRun `[p]evedash setup` to configure your dashboard.\nSee the README for full documentation.",
|
||||
"name": "EveDash",
|
||||
"short": "A modern, self-contained web dashboard for Red-DiscordBot.",
|
||||
"description": "EveDash is a sleek, Discord-inspired web dashboard that lets you manage your Red bot from a browser. Features Discord OAuth2 login, guild management, cog control, command toggles, real-time WebSocket updates, and a powerful SDK that lets any cog register its own settings pages with just a few lines of code.",
|
||||
"requirements": ["aiohttp"],
|
||||
"tags": ["dashboard", "web", "admin", "management", "ui"],
|
||||
"min_bot_version": "3.5.0",
|
||||
"type": "COG",
|
||||
"end_user_data_statement": "This cog stores Discord user IDs for authentication sessions and blacklist management. No message content is stored."
|
||||
}
|
||||
252
dashboard/sdk.py
Normal file
252
dashboard/sdk.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
"""
|
||||
EveDash SDK — Add dashboard support to any Red-DiscordBot cog.
|
||||
|
||||
Quick Start
|
||||
-----------
|
||||
1. Import the decorator and listener mixin:
|
||||
|
||||
from dashboard.sdk import dashboard_page
|
||||
|
||||
2. Add a listener so the dashboard discovers your cog:
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_dashboard_cog_add(self, dashboard_cog):
|
||||
dashboard_cog.rpc.add_third_party(self)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_dashboard_cog_remove(self, dashboard_cog):
|
||||
dashboard_cog.rpc.remove_third_party(self)
|
||||
|
||||
3. Decorate methods with @dashboard_page to expose them:
|
||||
|
||||
@dashboard_page(
|
||||
name="settings",
|
||||
description="Configure MyCog settings",
|
||||
methods=("GET", "POST"),
|
||||
is_owner=False,
|
||||
)
|
||||
async def dashboard_settings_page(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
# Save settings
|
||||
await self.config.guild(guild).welcome_channel.set(data.get("welcome_channel"))
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Settings saved!"}]}
|
||||
|
||||
# Return settings schema for the dashboard to auto-render
|
||||
current = await self.config.guild(guild).all()
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "EveDash auto-form",
|
||||
"settings": [
|
||||
{
|
||||
"id": "welcome_channel",
|
||||
"type": "channel_select",
|
||||
"label": "Welcome Channel",
|
||||
"description": "Channel where welcome messages are sent",
|
||||
"value": current.get("welcome_channel"),
|
||||
},
|
||||
{
|
||||
"id": "welcome_message",
|
||||
"type": "textarea",
|
||||
"label": "Welcome Message",
|
||||
"description": "Supports {user}, {server}, {member_count}",
|
||||
"value": current.get("welcome_message", ""),
|
||||
"placeholder": "Welcome {user} to {server}!",
|
||||
},
|
||||
{
|
||||
"id": "enabled",
|
||||
"type": "toggle",
|
||||
"label": "Enable Welcome Messages",
|
||||
"value": current.get("enabled", False),
|
||||
},
|
||||
{
|
||||
"id": "auto_role",
|
||||
"type": "role_select",
|
||||
"label": "Auto Role",
|
||||
"description": "Role assigned to new members",
|
||||
"value": current.get("auto_role"),
|
||||
},
|
||||
{
|
||||
"id": "log_channel",
|
||||
"type": "channel_select",
|
||||
"label": "Log Channel",
|
||||
"value": current.get("log_channel"),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
Setting Types
|
||||
-------------
|
||||
- ``text`` — Single-line text input
|
||||
- ``textarea`` — Multi-line text input
|
||||
- ``number`` — Numeric input (supports min/max)
|
||||
- ``toggle`` — Boolean on/off switch
|
||||
- ``select`` — Dropdown with options: [{"label": "...", "value": "..."}]
|
||||
- ``multi_select`` — Multi-select dropdown
|
||||
- ``channel_select`` — Discord channel picker
|
||||
- ``role_select`` — Discord role picker
|
||||
- ``color`` — Color picker
|
||||
- ``slider`` — Range slider (supports min/max/step)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import typing
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from redbot.core import commands
|
||||
|
||||
|
||||
def dashboard_page(
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
methods: tuple[str, ...] = ("GET",),
|
||||
is_owner: bool = False,
|
||||
hidden: bool = False,
|
||||
icon: str | None = None,
|
||||
order: int = 0,
|
||||
):
|
||||
"""Decorator to register a cog method as a dashboard page.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
URL-safe page name (e.g. ``"settings"``). Defaults to function name.
|
||||
description:
|
||||
Human-readable page description shown in the dashboard sidebar.
|
||||
methods:
|
||||
HTTP methods this page handles (``"GET"``, ``"POST"``, etc.).
|
||||
is_owner:
|
||||
If ``True``, only bot owners can access this page.
|
||||
hidden:
|
||||
If ``True``, the page won't appear in navigation but is still accessible.
|
||||
icon:
|
||||
Icon class for sidebar (e.g. ``"fas fa-cog"``). Defaults to a gear icon.
|
||||
order:
|
||||
Sort order in the sidebar. Lower numbers appear first.
|
||||
"""
|
||||
|
||||
def decorator(func: typing.Callable):
|
||||
if not inspect.iscoroutinefunction(func):
|
||||
raise TypeError("Dashboard page handler must be a coroutine function.")
|
||||
|
||||
func.__dashboard_params__ = {
|
||||
"name": name or func.__name__,
|
||||
"description": description or func.__doc__ or "No description",
|
||||
"methods": methods,
|
||||
"is_owner": is_owner,
|
||||
"hidden": hidden,
|
||||
"icon": icon or "fas fa-puzzle-piece",
|
||||
"order": order,
|
||||
}
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class ThirdPartyManager:
|
||||
"""Manages third-party cog registrations for the dashboard."""
|
||||
|
||||
def __init__(self, bot) -> None:
|
||||
self.bot = bot
|
||||
self.third_parties: dict[str, dict] = {}
|
||||
self.third_party_cogs: dict[str, commands.Cog] = {}
|
||||
|
||||
def add_third_party(self, cog) -> None:
|
||||
"""Register a cog's dashboard pages."""
|
||||
name = cog.qualified_name
|
||||
pages: dict[str, dict] = {}
|
||||
|
||||
for attr_name in dir(cog):
|
||||
try:
|
||||
func = getattr(cog, attr_name)
|
||||
if hasattr(func, "__dashboard_params__"):
|
||||
params = func.__dashboard_params__
|
||||
page_name = params["name"]
|
||||
pages[page_name] = {
|
||||
"func": func,
|
||||
"params": params,
|
||||
}
|
||||
except (TypeError, AttributeError):
|
||||
continue
|
||||
|
||||
if not pages:
|
||||
return
|
||||
|
||||
self.third_parties[name] = pages
|
||||
self.third_party_cogs[name] = cog
|
||||
|
||||
def remove_third_party(self, cog) -> None:
|
||||
"""Unregister a cog's dashboard pages."""
|
||||
name = cog.qualified_name
|
||||
self.third_parties.pop(name, None)
|
||||
self.third_party_cogs.pop(name, None)
|
||||
|
||||
def get_third_parties_info(self) -> dict:
|
||||
"""Get info about all registered third parties for the API."""
|
||||
result = {}
|
||||
for name, pages in self.third_parties.items():
|
||||
cog = self.third_party_cogs.get(name)
|
||||
result[name] = {
|
||||
"name": name,
|
||||
"description": getattr(cog, "__doc__", None) or "No description",
|
||||
"author": getattr(cog, "__author__", "Unknown"),
|
||||
"pages": {
|
||||
page_name: {
|
||||
k: v
|
||||
for k, v in page_data["params"].items()
|
||||
}
|
||||
for page_name, page_data in pages.items()
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
async def call_page(
|
||||
self,
|
||||
cog_name: str,
|
||||
page_name: str,
|
||||
*,
|
||||
user=None,
|
||||
guild=None,
|
||||
method: str = "GET",
|
||||
data: dict | None = None,
|
||||
) -> dict:
|
||||
"""Call a third-party page handler."""
|
||||
if cog_name not in self.third_parties:
|
||||
return {"status": 1, "error": "Third party not found"}
|
||||
pages = self.third_parties[cog_name]
|
||||
if page_name not in pages:
|
||||
return {"status": 1, "error": "Page not found"}
|
||||
|
||||
page = pages[page_name]
|
||||
params = page["params"]
|
||||
|
||||
if params["is_owner"]:
|
||||
if user is None or user.id not in self.bot.owner_ids:
|
||||
return {"status": 1, "error": "Forbidden — owner only"}
|
||||
|
||||
if method not in params["methods"]:
|
||||
return {"status": 1, "error": f"Method {method} not allowed"}
|
||||
|
||||
handler = page["func"]
|
||||
kwargs = {}
|
||||
sig = inspect.signature(handler)
|
||||
for param_name in sig.parameters:
|
||||
if param_name == "self":
|
||||
continue
|
||||
if param_name == "user":
|
||||
kwargs["user"] = user
|
||||
elif param_name == "guild":
|
||||
kwargs["guild"] = guild
|
||||
elif param_name == "method":
|
||||
kwargs["method"] = method
|
||||
elif param_name == "data":
|
||||
kwargs["data"] = data or {}
|
||||
|
||||
try:
|
||||
result = await handler(**kwargs)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"status": 1, "error": str(e)}
|
||||
882
dashboard/server.py
Normal file
882
dashboard/server.py
Normal file
|
|
@ -0,0 +1,882 @@
|
|||
"""
|
||||
EveDash — aiohttp web server with REST API, WebSocket, and static file serving.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import secrets
|
||||
import time
|
||||
import typing
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .dashboard import EveDash
|
||||
|
||||
log = logging.getLogger("red.evecogs.dashboard.server")
|
||||
|
||||
WEB_DIR = pathlib.Path(__file__).parent / "web"
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _json(data: dict, status: int = 200) -> web.Response:
|
||||
return web.json_response(data, status=status)
|
||||
|
||||
|
||||
def _err(msg: str, status: int = 400) -> web.Response:
|
||||
return _json({"error": msg}, status=status)
|
||||
|
||||
|
||||
def _make_token(user_id: int, secret: str) -> str:
|
||||
ts = str(int(time.time()))
|
||||
payload = f"{user_id}:{ts}"
|
||||
sig = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{payload}:{sig}"
|
||||
|
||||
|
||||
def _verify_token(token: str, secret: str, max_age: int = 86400 * 7) -> int | None:
|
||||
try:
|
||||
parts = token.split(":")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
user_id, ts, sig = int(parts[0]), int(parts[1]), parts[2]
|
||||
expected = hmac.new(secret.encode(), f"{user_id}:{ts}".encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
if time.time() - ts > max_age:
|
||||
return None
|
||||
return user_id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ── middleware ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_auth_middleware(cog: EveDash):
|
||||
@web.middleware
|
||||
async def auth_middleware(request: web.Request, handler):
|
||||
request["user_id"] = None
|
||||
request["is_owner"] = False
|
||||
|
||||
# Skip auth for static, login, callback, and health
|
||||
path = request.path
|
||||
if (
|
||||
not path.startswith("/api/")
|
||||
and not path.startswith("/ws")
|
||||
):
|
||||
return await handler(request)
|
||||
if path in ("/api/auth/login", "/api/auth/callback", "/api/health"):
|
||||
return await handler(request)
|
||||
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
if not token:
|
||||
token = request.cookies.get("eve_token")
|
||||
|
||||
if token:
|
||||
secret = await cog.config.secret_key()
|
||||
user_id = _verify_token(token, secret)
|
||||
if user_id:
|
||||
request["user_id"] = user_id
|
||||
request["is_owner"] = user_id in cog.bot.owner_ids
|
||||
|
||||
return await handler(request)
|
||||
|
||||
return auth_middleware
|
||||
|
||||
|
||||
def _require_auth(handler):
|
||||
async def wrapper(request: web.Request):
|
||||
if request["user_id"] is None:
|
||||
return _err("Unauthorized", 401)
|
||||
return await handler(request)
|
||||
wrapper.__name__ = handler.__name__
|
||||
return wrapper
|
||||
|
||||
|
||||
def _require_owner(handler):
|
||||
async def wrapper(request: web.Request):
|
||||
if request["user_id"] is None:
|
||||
return _err("Unauthorized", 401)
|
||||
if not request["is_owner"]:
|
||||
return _err("Forbidden — bot owner only", 403)
|
||||
return await handler(request)
|
||||
wrapper.__name__ = handler.__name__
|
||||
return wrapper
|
||||
|
||||
|
||||
# ── WebSocket manager ────────────────────────────────────────────────────
|
||||
|
||||
class WebSocketManager:
|
||||
def __init__(self):
|
||||
self.connections: dict[int, list[web.WebSocketResponse]] = {}
|
||||
|
||||
async def add(self, user_id: int, ws: web.WebSocketResponse):
|
||||
self.connections.setdefault(user_id, []).append(ws)
|
||||
|
||||
async def remove(self, user_id: int, ws: web.WebSocketResponse):
|
||||
if user_id in self.connections:
|
||||
self.connections[user_id] = [c for c in self.connections[user_id] if c is not ws]
|
||||
if not self.connections[user_id]:
|
||||
del self.connections[user_id]
|
||||
|
||||
async def broadcast(self, event: str, data: dict, owner_only: bool = False):
|
||||
msg = json.dumps({"event": event, "data": data})
|
||||
dead = []
|
||||
for uid, conns in self.connections.items():
|
||||
for ws in conns:
|
||||
try:
|
||||
await ws.send_str(msg)
|
||||
except Exception:
|
||||
dead.append((uid, ws))
|
||||
for uid, ws in dead:
|
||||
await self.remove(uid, ws)
|
||||
|
||||
async def send_to(self, user_id: int, event: str, data: dict):
|
||||
msg = json.dumps({"event": event, "data": data})
|
||||
for ws in self.connections.get(user_id, []):
|
||||
try:
|
||||
await ws.send_str(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Server class ─────────────────────────────────────────────────────────
|
||||
|
||||
class DashboardServer:
|
||||
def __init__(self, cog: EveDash):
|
||||
self.cog = cog
|
||||
self.bot = cog.bot
|
||||
self.app: web.Application | None = None
|
||||
self.runner: web.AppRunner | None = None
|
||||
self.site: web.TCPSite | None = None
|
||||
self.ws_manager = WebSocketManager()
|
||||
self._start_time = time.time()
|
||||
self._command_count = 0
|
||||
self._message_count = 0
|
||||
|
||||
async def start(self, host: str, port: int):
|
||||
self.app = web.Application(middlewares=[_build_auth_middleware(self.cog)])
|
||||
self._register_routes()
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(self.runner, host, port)
|
||||
await self.site.start()
|
||||
log.info(f"EveDash server started on http://{host}:{port}")
|
||||
|
||||
async def stop(self):
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
log.info("EveDash server stopped")
|
||||
|
||||
def _register_routes(self):
|
||||
r = self.app.router
|
||||
|
||||
# Health
|
||||
r.add_get("/api/health", self._health)
|
||||
|
||||
# Auth
|
||||
r.add_get("/api/auth/login", self._auth_login)
|
||||
r.add_get("/api/auth/callback", self._auth_callback)
|
||||
r.add_get("/api/auth/me", _require_auth(self._auth_me))
|
||||
r.add_post("/api/auth/logout", self._auth_logout)
|
||||
|
||||
# Bot
|
||||
r.add_get("/api/bot/info", _require_auth(self._bot_info))
|
||||
r.add_get("/api/bot/stats", _require_auth(self._bot_stats))
|
||||
|
||||
# Guilds
|
||||
r.add_get("/api/guilds", _require_auth(self._guilds_list))
|
||||
r.add_get("/api/guilds/{guild_id}", _require_auth(self._guild_detail))
|
||||
r.add_get("/api/guilds/{guild_id}/channels", _require_auth(self._guild_channels))
|
||||
r.add_get("/api/guilds/{guild_id}/roles", _require_auth(self._guild_roles))
|
||||
|
||||
# Guild settings
|
||||
r.add_get("/api/guilds/{guild_id}/settings", _require_auth(self._guild_settings_get))
|
||||
r.add_put("/api/guilds/{guild_id}/settings", _require_auth(self._guild_settings_update))
|
||||
|
||||
# Commands
|
||||
r.add_get("/api/guilds/{guild_id}/commands", _require_auth(self._guild_commands))
|
||||
r.add_put("/api/guilds/{guild_id}/commands/{command}", _require_auth(self._guild_command_toggle))
|
||||
|
||||
# Cog management (owner only)
|
||||
r.add_get("/api/cogs", _require_owner(self._cogs_list))
|
||||
r.add_post("/api/cogs/{cog_name}/load", _require_owner(self._cog_load))
|
||||
r.add_post("/api/cogs/{cog_name}/unload", _require_owner(self._cog_unload))
|
||||
r.add_post("/api/cogs/{cog_name}/reload", _require_owner(self._cog_reload))
|
||||
|
||||
# Third-party
|
||||
r.add_get("/api/guilds/{guild_id}/third-parties", _require_auth(self._third_parties_list))
|
||||
r.add_get("/api/guilds/{guild_id}/third-parties/{cog_name}/{page}", _require_auth(self._third_party_page))
|
||||
r.add_post("/api/guilds/{guild_id}/third-parties/{cog_name}/{page}", _require_auth(self._third_party_page))
|
||||
|
||||
# Admin
|
||||
r.add_get("/api/admin/config", _require_owner(self._admin_config))
|
||||
r.add_put("/api/admin/config", _require_owner(self._admin_config_update))
|
||||
r.add_get("/api/admin/blacklist", _require_owner(self._admin_blacklist))
|
||||
r.add_post("/api/admin/blacklist", _require_owner(self._admin_blacklist_add))
|
||||
r.add_delete("/api/admin/blacklist/{user_id}", _require_owner(self._admin_blacklist_remove))
|
||||
r.add_get("/api/admin/whitelist", _require_owner(self._admin_whitelist))
|
||||
|
||||
# WebSocket
|
||||
r.add_get("/ws", self._ws_handler)
|
||||
|
||||
# Static files (SPA)
|
||||
r.add_get("/", self._serve_index)
|
||||
r.add_static("/static/", path=str(WEB_DIR), name="static", show_index=False)
|
||||
# Catch-all for SPA routing
|
||||
r.add_get("/{path:.*}", self._serve_spa)
|
||||
|
||||
# ── Health ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _health(self, request: web.Request) -> web.Response:
|
||||
return _json({"status": "ok", "uptime": int(time.time() - self._start_time)})
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _auth_login(self, request: web.Request) -> web.Response:
|
||||
client_id = await self.cog.config.client_id()
|
||||
redirect_uri = await self.cog.config.redirect_uri()
|
||||
if not client_id or not redirect_uri:
|
||||
return _err("Dashboard not configured. Run [p]evedash setup", 500)
|
||||
state = secrets.token_urlsafe(32)
|
||||
url = (
|
||||
f"https://discord.com/api/oauth2/authorize"
|
||||
f"?client_id={client_id}"
|
||||
f"&redirect_uri={redirect_uri}"
|
||||
f"&response_type=code"
|
||||
f"&scope=identify+guilds"
|
||||
f"&state={state}"
|
||||
)
|
||||
return _json({"url": url, "state": state})
|
||||
|
||||
async def _auth_callback(self, request: web.Request) -> web.Response:
|
||||
code = request.query.get("code")
|
||||
if not code:
|
||||
return _err("Missing authorization code")
|
||||
|
||||
client_id = await self.cog.config.client_id()
|
||||
client_secret = await self.cog.config.client_secret()
|
||||
redirect_uri = await self.cog.config.redirect_uri()
|
||||
|
||||
# Exchange code for token
|
||||
async with aiohttp.ClientSession() as session:
|
||||
resp = await session.post(
|
||||
"https://discord.com/api/oauth2/token",
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if resp.status != 200:
|
||||
return _err("Failed to exchange authorization code")
|
||||
token_data = await resp.json()
|
||||
|
||||
# Get user info
|
||||
resp2 = await session.get(
|
||||
"https://discord.com/api/users/@me",
|
||||
headers={"Authorization": f"Bearer {token_data['access_token']}"},
|
||||
)
|
||||
if resp2.status != 200:
|
||||
return _err("Failed to fetch user info")
|
||||
user_data = await resp2.json()
|
||||
|
||||
user_id = int(user_data["id"])
|
||||
|
||||
# Check access: must be bot owner or share a guild
|
||||
is_owner = user_id in self.bot.owner_ids
|
||||
has_guild = any(g.get_member(user_id) for g in self.bot.guilds)
|
||||
if not is_owner and not has_guild:
|
||||
return _err("You don't share any servers with this bot", 403)
|
||||
|
||||
# Check blacklist
|
||||
blacklist = await self.cog.config.blacklist()
|
||||
if user_id in blacklist:
|
||||
return _err("You are blacklisted from the dashboard", 403)
|
||||
|
||||
# Generate token
|
||||
secret = await self.cog.config.secret_key()
|
||||
token = _make_token(user_id, secret)
|
||||
|
||||
# Redirect to frontend with token
|
||||
base_url = redirect_uri.rsplit("/", 2)[0] # Remove /api/auth/callback
|
||||
resp = web.HTTPFound(f"{base_url}/#/callback?token={token}")
|
||||
resp.set_cookie(
|
||||
"eve_token", token,
|
||||
max_age=86400 * 7,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
return resp
|
||||
|
||||
async def _auth_me(self, request: web.Request) -> web.Response:
|
||||
user_id = request["user_id"]
|
||||
user = self.bot.get_user(user_id)
|
||||
if not user:
|
||||
# Try to fetch
|
||||
try:
|
||||
user = await self.bot.fetch_user(user_id)
|
||||
except Exception:
|
||||
return _err("User not found", 404)
|
||||
|
||||
return _json({
|
||||
"id": str(user.id),
|
||||
"username": user.name,
|
||||
"display_name": user.display_name,
|
||||
"avatar": str(user.display_avatar.url) if user.display_avatar else None,
|
||||
"is_owner": user.id in self.bot.owner_ids,
|
||||
})
|
||||
|
||||
async def _auth_logout(self, request: web.Request) -> web.Response:
|
||||
resp = _json({"status": "ok"})
|
||||
resp.del_cookie("eve_token")
|
||||
return resp
|
||||
|
||||
# ── Bot info ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _bot_info(self, request: web.Request) -> web.Response:
|
||||
bot = self.bot
|
||||
app_info = await bot.application_info()
|
||||
prefixes = await bot.get_valid_prefixes()
|
||||
return _json({
|
||||
"name": bot.user.name,
|
||||
"id": str(bot.user.id),
|
||||
"avatar": str(bot.user.display_avatar.url),
|
||||
"discriminator": bot.user.discriminator,
|
||||
"guild_count": len(bot.guilds),
|
||||
"user_count": sum(g.member_count or 0 for g in bot.guilds),
|
||||
"channel_count": sum(len(g.channels) for g in bot.guilds),
|
||||
"cog_count": len(bot.cogs),
|
||||
"command_count": len(set(bot.walk_commands())),
|
||||
"prefixes": prefixes[:5],
|
||||
"description": app_info.description or "",
|
||||
"owner": {
|
||||
"id": str(app_info.owner.id) if app_info.owner else None,
|
||||
"name": str(app_info.owner) if app_info.owner else None,
|
||||
},
|
||||
"latency_ms": round(bot.latency * 1000, 1),
|
||||
"uptime_seconds": int(time.time() - self._start_time),
|
||||
"red_version": getattr(bot, "_red_version", "Unknown"),
|
||||
})
|
||||
|
||||
async def _bot_stats(self, request: web.Request) -> web.Response:
|
||||
bot = self.bot
|
||||
guilds_by_size = sorted(bot.guilds, key=lambda g: g.member_count or 0, reverse=True)
|
||||
top_guilds = [
|
||||
{"name": g.name, "id": str(g.id), "members": g.member_count or 0, "icon": str(g.icon.url) if g.icon else None}
|
||||
for g in guilds_by_size[:10]
|
||||
]
|
||||
|
||||
# Channel type breakdown
|
||||
text_channels = sum(len(g.text_channels) for g in bot.guilds)
|
||||
voice_channels = sum(len(g.voice_channels) for g in bot.guilds)
|
||||
categories = sum(len(g.categories) for g in bot.guilds)
|
||||
|
||||
return _json({
|
||||
"guilds": len(bot.guilds),
|
||||
"users": sum(g.member_count or 0 for g in bot.guilds),
|
||||
"text_channels": text_channels,
|
||||
"voice_channels": voice_channels,
|
||||
"categories": categories,
|
||||
"cogs_loaded": len(bot.cogs),
|
||||
"commands": len(set(bot.walk_commands())),
|
||||
"latency_ms": round(bot.latency * 1000, 1),
|
||||
"uptime_seconds": int(time.time() - self._start_time),
|
||||
"top_guilds": top_guilds,
|
||||
"message_count": self._message_count,
|
||||
"command_count": self._command_count,
|
||||
})
|
||||
|
||||
# ── Guilds ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _guilds_list(self, request: web.Request) -> web.Response:
|
||||
user_id = request["user_id"]
|
||||
is_owner = request["is_owner"]
|
||||
query = request.query.get("q", "").lower()
|
||||
|
||||
guilds = []
|
||||
for g in self.bot.guilds:
|
||||
if not is_owner:
|
||||
member = g.get_member(user_id)
|
||||
if not member:
|
||||
continue
|
||||
if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator):
|
||||
continue
|
||||
if query and query not in g.name.lower():
|
||||
continue
|
||||
guilds.append({
|
||||
"id": str(g.id),
|
||||
"name": g.name,
|
||||
"icon": str(g.icon.url) if g.icon else None,
|
||||
"member_count": g.member_count or 0,
|
||||
"channel_count": len(g.channels),
|
||||
"role_count": len(g.roles),
|
||||
"owner_id": str(g.owner_id),
|
||||
})
|
||||
|
||||
guilds.sort(key=lambda x: x["member_count"], reverse=True)
|
||||
return _json({"guilds": guilds})
|
||||
|
||||
async def _guild_detail(self, request: web.Request) -> web.Response:
|
||||
guild = self._get_guild(request)
|
||||
if not guild:
|
||||
return _err("Guild not found", 404)
|
||||
|
||||
if not self._check_guild_access(request, guild):
|
||||
return _err("Forbidden", 403)
|
||||
|
||||
return _json({
|
||||
"id": str(guild.id),
|
||||
"name": guild.name,
|
||||
"icon": str(guild.icon.url) if guild.icon else None,
|
||||
"banner": str(guild.banner.url) if guild.banner else None,
|
||||
"member_count": guild.member_count or 0,
|
||||
"channel_count": len(guild.channels),
|
||||
"text_channels": len(guild.text_channels),
|
||||
"voice_channels": len(guild.voice_channels),
|
||||
"role_count": len(guild.roles),
|
||||
"emoji_count": len(guild.emojis),
|
||||
"boost_level": guild.premium_tier,
|
||||
"boost_count": guild.premium_subscription_count or 0,
|
||||
"owner": {"id": str(guild.owner_id), "name": str(guild.owner) if guild.owner else "Unknown"},
|
||||
"created_at": guild.created_at.isoformat(),
|
||||
"features": list(guild.features),
|
||||
})
|
||||
|
||||
async def _guild_channels(self, request: web.Request) -> web.Response:
|
||||
guild = self._get_guild(request)
|
||||
if not guild:
|
||||
return _err("Guild not found", 404)
|
||||
if not self._check_guild_access(request, guild):
|
||||
return _err("Forbidden", 403)
|
||||
|
||||
channels = []
|
||||
for c in sorted(guild.channels, key=lambda x: (x.position, x.name)):
|
||||
channels.append({
|
||||
"id": str(c.id),
|
||||
"name": c.name,
|
||||
"type": str(c.type),
|
||||
"position": c.position,
|
||||
"category": str(c.category_id) if c.category_id else None,
|
||||
})
|
||||
return _json({"channels": channels})
|
||||
|
||||
async def _guild_roles(self, request: web.Request) -> web.Response:
|
||||
guild = self._get_guild(request)
|
||||
if not guild:
|
||||
return _err("Guild not found", 404)
|
||||
if not self._check_guild_access(request, guild):
|
||||
return _err("Forbidden", 403)
|
||||
|
||||
roles = []
|
||||
for r in sorted(guild.roles, key=lambda x: x.position, reverse=True):
|
||||
roles.append({
|
||||
"id": str(r.id),
|
||||
"name": r.name,
|
||||
"color": str(r.color),
|
||||
"position": r.position,
|
||||
"mentionable": r.mentionable,
|
||||
"hoist": r.hoist,
|
||||
"managed": r.managed,
|
||||
"member_count": len(r.members),
|
||||
})
|
||||
return _json({"roles": roles})
|
||||
|
||||
# ── Guild settings ───────────────────────────────────────────────────
|
||||
|
||||
async def _guild_settings_get(self, request: web.Request) -> web.Response:
|
||||
guild = self._get_guild(request)
|
||||
if not guild:
|
||||
return _err("Guild not found", 404)
|
||||
if not self._check_guild_access(request, guild):
|
||||
return _err("Forbidden", 403)
|
||||
|
||||
prefixes = await self.bot.get_valid_prefixes(guild)
|
||||
# Get admin/mod roles from Red's config
|
||||
admin_role_ids = await self.bot._config.guild(guild).admin_role()
|
||||
mod_role_ids = await self.bot._config.guild(guild).mod_role()
|
||||
|
||||
# Ignored status
|
||||
ignored = await self.bot._config.guild(guild).ignored()
|
||||
|
||||
# Disabled commands
|
||||
disabled_cmds = await self.bot._config.guild(guild).disabled_commands()
|
||||
|
||||
# Bot nickname
|
||||
bot_member = guild.get_member(self.bot.user.id)
|
||||
bot_nick = bot_member.nick if bot_member else None
|
||||
|
||||
return _json({
|
||||
"prefixes": prefixes,
|
||||
"admin_roles": [str(r) for r in admin_role_ids],
|
||||
"mod_roles": [str(r) for r in mod_role_ids],
|
||||
"ignored": ignored,
|
||||
"disabled_commands": list(disabled_cmds),
|
||||
"bot_nickname": bot_nick,
|
||||
})
|
||||
|
||||
async def _guild_settings_update(self, request: web.Request) -> web.Response:
|
||||
guild = self._get_guild(request)
|
||||
if not guild:
|
||||
return _err("Guild not found", 404)
|
||||
if not self._check_guild_access(request, guild):
|
||||
return _err("Forbidden", 403)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if "prefixes" in data:
|
||||
prefixes = data["prefixes"]
|
||||
if isinstance(prefixes, list) and prefixes:
|
||||
await self.bot._config.guild(guild).prefix.set(prefixes)
|
||||
|
||||
if "admin_roles" in data:
|
||||
role_ids = [int(r) for r in data["admin_roles"] if r]
|
||||
await self.bot._config.guild(guild).admin_role.set(role_ids)
|
||||
|
||||
if "mod_roles" in data:
|
||||
role_ids = [int(r) for r in data["mod_roles"] if r]
|
||||
await self.bot._config.guild(guild).mod_role.set(role_ids)
|
||||
|
||||
if "bot_nickname" in data:
|
||||
bot_member = guild.get_member(self.bot.user.id)
|
||||
if bot_member:
|
||||
try:
|
||||
await bot_member.edit(nick=data["bot_nickname"] or None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _json({"status": "ok"})
|
||||
|
||||
# ── Commands ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _guild_commands(self, request: web.Request) -> web.Response:
|
||||
guild = self._get_guild(request)
|
||||
if not guild:
|
||||
return _err("Guild not found", 404)
|
||||
if not self._check_guild_access(request, guild):
|
||||
return _err("Forbidden", 403)
|
||||
|
||||
disabled_cmds = await self.bot._config.guild(guild).disabled_commands()
|
||||
|
||||
cogs_data = {}
|
||||
for cmd in sorted(self.bot.walk_commands(), key=lambda c: c.qualified_name):
|
||||
cog_name = cmd.cog_name or "No Category"
|
||||
if cog_name not in cogs_data:
|
||||
cog = cmd.cog
|
||||
cogs_data[cog_name] = {
|
||||
"name": cog_name,
|
||||
"description": (cog.__doc__ or "").strip().split("\n")[0] if cog else "",
|
||||
"commands": [],
|
||||
}
|
||||
|
||||
cogs_data[cog_name]["commands"].append({
|
||||
"name": cmd.qualified_name,
|
||||
"description": cmd.short_doc or "",
|
||||
"enabled": cmd.qualified_name not in disabled_cmds,
|
||||
"hidden": cmd.hidden,
|
||||
"aliases": list(cmd.aliases),
|
||||
"signature": cmd.signature,
|
||||
"parent": cmd.parent.qualified_name if cmd.parent else None,
|
||||
})
|
||||
|
||||
return _json({"cogs": cogs_data})
|
||||
|
||||
async def _guild_command_toggle(self, request: web.Request) -> web.Response:
|
||||
guild = self._get_guild(request)
|
||||
if not guild:
|
||||
return _err("Guild not found", 404)
|
||||
if not self._check_guild_access(request, guild):
|
||||
return _err("Forbidden", 403)
|
||||
|
||||
cmd_name = request.match_info["command"]
|
||||
data = await request.json()
|
||||
enabled = data.get("enabled", True)
|
||||
|
||||
async with self.bot._config.guild(guild).disabled_commands() as disabled:
|
||||
if enabled and cmd_name in disabled:
|
||||
disabled.remove(cmd_name)
|
||||
elif not enabled and cmd_name not in disabled:
|
||||
disabled.append(cmd_name)
|
||||
|
||||
return _json({"status": "ok", "command": cmd_name, "enabled": enabled})
|
||||
|
||||
# ── Cog management ───────────────────────────────────────────────────
|
||||
|
||||
async def _cogs_list(self, request: web.Request) -> web.Response:
|
||||
loaded = {}
|
||||
for name, cog in self.bot.cogs.items():
|
||||
loaded[name] = {
|
||||
"name": name,
|
||||
"loaded": True,
|
||||
"description": (cog.__doc__ or "").strip().split("\n")[0],
|
||||
"author": getattr(cog, "__author__", "Unknown"),
|
||||
"commands": [c.qualified_name for c in cog.get_commands()],
|
||||
}
|
||||
|
||||
# Get available cogs from downloader if available
|
||||
available = []
|
||||
downloader = self.bot.get_cog("Downloader")
|
||||
if downloader:
|
||||
try:
|
||||
installed = await downloader.installed_cogs()
|
||||
for inst_cog in installed:
|
||||
cog_name = inst_cog.name
|
||||
if cog_name not in loaded:
|
||||
available.append({
|
||||
"name": cog_name,
|
||||
"loaded": False,
|
||||
"description": "",
|
||||
"repo": str(getattr(inst_cog, "repo_name", "unknown")),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _json({"loaded": loaded, "available": available})
|
||||
|
||||
async def _cog_load(self, request: web.Request) -> web.Response:
|
||||
cog_name = request.match_info["cog_name"]
|
||||
try:
|
||||
await self.bot.load_extension(f"cogs.{cog_name}")
|
||||
return _json({"status": "ok", "message": f"{cog_name} loaded"})
|
||||
except Exception as e:
|
||||
# Try Red's cog loading
|
||||
try:
|
||||
spec = await self.bot._cog_mgr.find_cog(cog_name)
|
||||
if spec:
|
||||
await self.bot.load_extension(spec)
|
||||
return _json({"status": "ok", "message": f"{cog_name} loaded"})
|
||||
except Exception as e2:
|
||||
return _err(f"Failed to load {cog_name}: {e2}")
|
||||
return _err(f"Failed to load {cog_name}: {e}")
|
||||
|
||||
async def _cog_unload(self, request: web.Request) -> web.Response:
|
||||
cog_name = request.match_info["cog_name"]
|
||||
if cog_name.lower() == "dashboard":
|
||||
return _err("Cannot unload the Dashboard cog from the dashboard")
|
||||
try:
|
||||
await self.bot.remove_cog(cog_name)
|
||||
return _json({"status": "ok", "message": f"{cog_name} unloaded"})
|
||||
except Exception as e:
|
||||
return _err(f"Failed to unload {cog_name}: {e}")
|
||||
|
||||
async def _cog_reload(self, request: web.Request) -> web.Response:
|
||||
cog_name = request.match_info["cog_name"]
|
||||
try:
|
||||
cog = self.bot.get_cog(cog_name)
|
||||
if cog:
|
||||
module = cog.__module__
|
||||
await self.bot.remove_cog(cog_name)
|
||||
await self.bot.load_extension(module)
|
||||
return _json({"status": "ok", "message": f"{cog_name} reloaded"})
|
||||
return _err(f"Cog {cog_name} not found")
|
||||
except Exception as e:
|
||||
return _err(f"Failed to reload {cog_name}: {e}")
|
||||
|
||||
# ── Third-party integration ──────────────────────────────────────────
|
||||
|
||||
async def _third_parties_list(self, request: web.Request) -> web.Response:
|
||||
guild = self._get_guild(request)
|
||||
if not guild:
|
||||
return _err("Guild not found", 404)
|
||||
if not self._check_guild_access(request, guild):
|
||||
return _err("Forbidden", 403)
|
||||
|
||||
tp_info = self.cog.third_parties.get_third_parties_info()
|
||||
return _json({"third_parties": tp_info})
|
||||
|
||||
async def _third_party_page(self, request: web.Request) -> web.Response:
|
||||
guild = self._get_guild(request)
|
||||
if not guild:
|
||||
return _err("Guild not found", 404)
|
||||
if not self._check_guild_access(request, guild):
|
||||
return _err("Forbidden", 403)
|
||||
|
||||
cog_name = request.match_info["cog_name"]
|
||||
page_name = request.match_info["page"]
|
||||
user = self.bot.get_user(request["user_id"])
|
||||
|
||||
data = None
|
||||
if request.method == "POST":
|
||||
data = await request.json()
|
||||
|
||||
result = await self.cog.third_parties.call_page(
|
||||
cog_name, page_name,
|
||||
user=user, guild=guild,
|
||||
method=request.method, data=data,
|
||||
)
|
||||
return _json(result)
|
||||
|
||||
# ── Admin ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _admin_config(self, request: web.Request) -> web.Response:
|
||||
prefixes = await self.bot._config.prefix()
|
||||
return _json({
|
||||
"global_prefixes": prefixes,
|
||||
"owner_ids": [str(uid) for uid in self.bot.owner_ids],
|
||||
"embeds": await self.bot._config.embeds(),
|
||||
"color": await self.bot._config.color(),
|
||||
"fuzzy": await self.bot._config.fuzzy(),
|
||||
"invite_public": await self.bot._config.invite_public(),
|
||||
"invite_perm": await self.bot._config.invite_perm(),
|
||||
"disabled_commands": list(await self.bot._config.disabled_commands()),
|
||||
"locale": await self.bot._config.locale(),
|
||||
})
|
||||
|
||||
async def _admin_config_update(self, request: web.Request) -> web.Response:
|
||||
data = await request.json()
|
||||
|
||||
if "global_prefixes" in data:
|
||||
prefixes = data["global_prefixes"]
|
||||
if isinstance(prefixes, list) and prefixes:
|
||||
await self.bot._config.prefix.set(prefixes)
|
||||
|
||||
if "embeds" in data:
|
||||
await self.bot._config.embeds.set(bool(data["embeds"]))
|
||||
|
||||
if "fuzzy" in data:
|
||||
await self.bot._config.fuzzy.set(bool(data["fuzzy"]))
|
||||
|
||||
if "invite_public" in data:
|
||||
await self.bot._config.invite_public.set(bool(data["invite_public"]))
|
||||
|
||||
if "locale" in data:
|
||||
await self.bot._config.locale.set(data["locale"])
|
||||
|
||||
return _json({"status": "ok"})
|
||||
|
||||
async def _admin_blacklist(self, request: web.Request) -> web.Response:
|
||||
blacklist = await self.cog.config.blacklist()
|
||||
users = []
|
||||
for uid in blacklist:
|
||||
user = self.bot.get_user(uid)
|
||||
users.append({
|
||||
"id": str(uid),
|
||||
"name": str(user) if user else f"Unknown#{uid}",
|
||||
"avatar": str(user.display_avatar.url) if user and user.display_avatar else None,
|
||||
})
|
||||
return _json({"blacklist": users})
|
||||
|
||||
async def _admin_blacklist_add(self, request: web.Request) -> web.Response:
|
||||
data = await request.json()
|
||||
user_id = int(data.get("user_id", 0))
|
||||
if not user_id:
|
||||
return _err("Invalid user ID")
|
||||
async with self.cog.config.blacklist() as bl:
|
||||
if user_id not in bl:
|
||||
bl.append(user_id)
|
||||
return _json({"status": "ok"})
|
||||
|
||||
async def _admin_blacklist_remove(self, request: web.Request) -> web.Response:
|
||||
user_id = int(request.match_info["user_id"])
|
||||
async with self.cog.config.blacklist() as bl:
|
||||
if user_id in bl:
|
||||
bl.remove(user_id)
|
||||
return _json({"status": "ok"})
|
||||
|
||||
async def _admin_whitelist(self, request: web.Request) -> web.Response:
|
||||
# Red's global whitelist
|
||||
try:
|
||||
whitelist = await self.bot._config.whitelist()
|
||||
users = []
|
||||
for uid in whitelist:
|
||||
user = self.bot.get_user(uid)
|
||||
users.append({
|
||||
"id": str(uid),
|
||||
"name": str(user) if user else f"Unknown#{uid}",
|
||||
})
|
||||
return _json({"whitelist": users})
|
||||
except Exception:
|
||||
return _json({"whitelist": []})
|
||||
|
||||
# ── WebSocket ────────────────────────────────────────────────────────
|
||||
|
||||
async def _ws_handler(self, request: web.Request) -> web.WebSocketResponse:
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
|
||||
# Authenticate via first message
|
||||
user_id = request["user_id"]
|
||||
if not user_id:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.receive(), timeout=10)
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
data = json.loads(msg.data)
|
||||
secret = await self.cog.config.secret_key()
|
||||
user_id = _verify_token(data.get("token", ""), secret)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not user_id:
|
||||
await ws.close(message=b"Unauthorized")
|
||||
return ws
|
||||
|
||||
await self.ws_manager.add(user_id, ws)
|
||||
await ws.send_json({"event": "connected", "data": {"user_id": str(user_id)}})
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
data = json.loads(msg.data)
|
||||
if data.get("type") == "ping":
|
||||
await ws.send_json({"event": "pong", "data": {}})
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
break
|
||||
finally:
|
||||
await self.ws_manager.remove(user_id, ws)
|
||||
|
||||
return ws
|
||||
|
||||
# ── Static serving ───────────────────────────────────────────────────
|
||||
|
||||
async def _serve_index(self, request: web.Request) -> web.Response:
|
||||
index_path = WEB_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
return web.FileResponse(index_path)
|
||||
return _err("Frontend not found", 500)
|
||||
|
||||
async def _serve_spa(self, request: web.Request) -> web.Response:
|
||||
path = request.match_info.get("path", "")
|
||||
file_path = WEB_DIR / path
|
||||
|
||||
# Serve actual files if they exist
|
||||
if file_path.is_file():
|
||||
return web.FileResponse(file_path)
|
||||
|
||||
# Otherwise serve index.html (SPA routing)
|
||||
index_path = WEB_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
return web.FileResponse(index_path)
|
||||
return _err("Not found", 404)
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _get_guild(self, request: web.Request):
|
||||
try:
|
||||
guild_id = int(request.match_info["guild_id"])
|
||||
return self.bot.get_guild(guild_id)
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
|
||||
def _check_guild_access(self, request: web.Request, guild) -> bool:
|
||||
if request["is_owner"]:
|
||||
return True
|
||||
user_id = request["user_id"]
|
||||
member = guild.get_member(user_id)
|
||||
if not member:
|
||||
return False
|
||||
return member.guild_permissions.manage_guild or member.guild_permissions.administrator
|
||||
1116
dashboard/web/app.js
Normal file
1116
dashboard/web/app.js
Normal file
File diff suppressed because it is too large
Load diff
30
dashboard/web/index.html
Normal file
30
dashboard/web/index.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EveDash — Bot Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Loading screen -->
|
||||
<div id="loading-screen" class="loading-screen">
|
||||
<div class="loading-content">
|
||||
<div class="loading-logo">
|
||||
<i class="fas fa-robot"></i>
|
||||
</div>
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading EveDash...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
984
dashboard/web/style.css
Normal file
984
dashboard/web/style.css
Normal file
|
|
@ -0,0 +1,984 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
EveDash — Discord-inspired dark theme dashboard
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* Core palette */
|
||||
--bg-primary: #0b0d15;
|
||||
--bg-secondary: #111320;
|
||||
--bg-tertiary: #171a2c;
|
||||
--bg-card: #1a1d30;
|
||||
--bg-card-hover: #1f2238;
|
||||
--bg-input: #12142280;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* Accent */
|
||||
--accent: #5865F2;
|
||||
--accent-hover: #4752c4;
|
||||
--accent-soft: rgba(88, 101, 242, 0.12);
|
||||
--accent-glow: rgba(88, 101, 242, 0.25);
|
||||
|
||||
/* Semantic */
|
||||
--success: #57F287;
|
||||
--success-soft: rgba(87, 242, 135, 0.12);
|
||||
--warning: #FEE75C;
|
||||
--warning-soft: rgba(254, 231, 92, 0.12);
|
||||
--danger: #ED4245;
|
||||
--danger-soft: rgba(237, 66, 69, 0.12);
|
||||
--info: #5bc0eb;
|
||||
--info-soft: rgba(91, 192, 235, 0.12);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #f2f3f5;
|
||||
--text-secondary: #8b8fa3;
|
||||
--text-muted: #5c5f73;
|
||||
--text-link: #00b0f4;
|
||||
|
||||
/* Border */
|
||||
--border: #25283d;
|
||||
--border-hover: #353853;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-collapsed: 72px;
|
||||
|
||||
/* Misc */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────────────────── */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html {
|
||||
font-size: 15px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--text-link); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
img { max-width: 100%; height: auto; }
|
||||
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }
|
||||
|
||||
/* ── Loading Screen ─────────────────────────────────────────────────── */
|
||||
|
||||
.loading-screen {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
z-index: 10000;
|
||||
transition: opacity 0.4s ease, visibility 0.4s ease;
|
||||
}
|
||||
.loading-screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
|
||||
|
||||
.loading-content { text-align: center; }
|
||||
|
||||
.loading-logo {
|
||||
font-size: 3rem;
|
||||
color: var(--accent);
|
||||
margin-bottom: 1.5rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 36px; height: 36px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.loading-content p { color: var(--text-secondary); font-size: 0.9rem; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } }
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
transition: margin-left var(--transition);
|
||||
}
|
||||
|
||||
.main-content.sidebar-collapsed { margin-left: var(--sidebar-collapsed); }
|
||||
|
||||
.page-header {
|
||||
padding: 2rem 2.5rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.page-header p { color: var(--text-secondary); font-size: 0.9rem; }
|
||||
|
||||
.page-body { padding: 2rem 2.5rem; }
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
position: fixed; left: 0; top: 0; bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
z-index: 100;
|
||||
transition: width var(--transition);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar.collapsed { width: var(--sidebar-collapsed); }
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.sidebar-brand .brand-icon {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.1rem; color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand .brand-text {
|
||||
font-size: 1.05rem; font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-brand .brand-text span { color: var(--accent); }
|
||||
|
||||
.sidebar.collapsed .brand-text,
|
||||
.sidebar.collapsed .nav-label,
|
||||
.sidebar.collapsed .nav-text,
|
||||
.sidebar.collapsed .guild-selector-text,
|
||||
.sidebar.collapsed .nav-section-title,
|
||||
.sidebar.collapsed .sidebar-footer-info { display: none; }
|
||||
|
||||
.sidebar-toggle {
|
||||
position: absolute; top: 18px; right: -14px;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
z-index: 101;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.sidebar-toggle:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
/* Guild Selector */
|
||||
.guild-selector {
|
||||
margin: 1rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; gap: 0.65rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
.guild-selector:hover { border-color: var(--accent); background: var(--bg-card); }
|
||||
|
||||
.guild-selector .guild-icon {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.guild-selector .guild-icon-placeholder {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.85rem; font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guild-selector-text {
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.guild-selector-text .guild-name {
|
||||
font-size: 0.85rem; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.guild-selector-text .guild-members {
|
||||
font-size: 0.7rem; color: var(--text-muted);
|
||||
}
|
||||
.guild-selector .chevron {
|
||||
color: var(--text-muted); font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Guild dropdown */
|
||||
.guild-dropdown {
|
||||
position: absolute;
|
||||
left: 0; top: calc(100% + 6px);
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 200;
|
||||
display: none;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.guild-dropdown.open { display: block; }
|
||||
|
||||
.guild-dropdown-search {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.4rem;
|
||||
outline: none;
|
||||
}
|
||||
.guild-dropdown-search:focus { border-color: var(--accent); }
|
||||
|
||||
.guild-dropdown-item {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.guild-dropdown-item:hover { background: var(--accent-soft); }
|
||||
.guild-dropdown-item.active { background: var(--accent-soft); }
|
||||
.guild-dropdown-item img {
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm); object-fit: cover;
|
||||
}
|
||||
.guild-dropdown-item .placeholder-icon {
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
}
|
||||
.guild-dropdown-item span {
|
||||
font-size: 0.82rem; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.sidebar-nav {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
font-size: 0.65rem; font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
padding: 0.75rem 0.5rem 0.35rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.87rem; font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-item i { width: 18px; text-align: center; font-size: 0.9rem; flex-shrink: 0; }
|
||||
.nav-item:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||
.nav-item.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-item.active i { color: var(--accent); }
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 0.65rem;
|
||||
}
|
||||
|
||||
.sidebar-footer .user-avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer-info {
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.sidebar-footer-info .user-name {
|
||||
font-size: 0.82rem; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.sidebar-footer-info .user-role {
|
||||
font-size: 0.7rem; color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-footer .logout-btn {
|
||||
background: none; border: none;
|
||||
color: var(--text-muted); font-size: 0.9rem;
|
||||
padding: 0.3rem;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.sidebar-footer .logout-btn:hover { color: var(--danger); }
|
||||
|
||||
/* ── Login Page ─────────────────────────────────────────────────────── */
|
||||
|
||||
.login-page {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(88, 101, 242, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(87, 242, 135, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 50% 80%, rgba(237, 66, 69, 0.04) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 3rem 2.5rem;
|
||||
width: 420px; max-width: 90vw;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.login-card .logo { font-size: 3rem; color: var(--accent); margin-bottom: 1rem; }
|
||||
.login-card h1 { font-size: 1.7rem; font-weight: 800; margin-bottom: 0.4rem; }
|
||||
.login-card h1 span { color: var(--accent); }
|
||||
.login-card p { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 2rem; }
|
||||
|
||||
.login-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.6rem;
|
||||
padding: 0.8rem 2rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1rem; font-weight: 600;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.login-btn:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 4px 15px var(--accent-glow); }
|
||||
.login-btn:active { transform: translateY(0); }
|
||||
.login-btn i { font-size: 1.2rem; }
|
||||
|
||||
.login-features {
|
||||
margin-top: 2rem;
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
.login-feature {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
font-size: 0.78rem; color: var(--text-secondary);
|
||||
}
|
||||
.login-feature i { color: var(--success); font-size: 0.7rem; }
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.card:hover { border-color: var(--border-hover); }
|
||||
|
||||
.card-header {
|
||||
padding: 1.1rem 1.3rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.card-header h3 {
|
||||
font-size: 0.95rem; font-weight: 600;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.card-header h3 i { color: var(--accent); font-size: 0.85rem; }
|
||||
|
||||
.card-body { padding: 1.3rem; }
|
||||
|
||||
/* Stat cards */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
transition: all var(--transition);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute; top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
.stat-card:hover::before { opacity: 1; }
|
||||
|
||||
.stat-card .stat-icon {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.stat-card .stat-icon.blue { background: var(--accent-soft); color: var(--accent); }
|
||||
.stat-card .stat-icon.green { background: var(--success-soft); color: var(--success); }
|
||||
.stat-card .stat-icon.yellow { background: var(--warning-soft); color: var(--warning); }
|
||||
.stat-card .stat-icon.red { background: var(--danger-soft); color: var(--danger); }
|
||||
.stat-card .stat-icon.cyan { background: var(--info-soft); color: var(--info); }
|
||||
|
||||
.stat-card .stat-value { font-size: 1.7rem; font-weight: 800; line-height: 1.2; }
|
||||
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-secondary); margin-top: 0.15rem; }
|
||||
|
||||
/* ── Tables ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.table-container { overflow-x: auto; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.8rem 1rem;
|
||||
font-size: 0.87rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255,255,255, 0.02); }
|
||||
|
||||
/* ── Forms & Inputs ─────────────────────────────────────────────────── */
|
||||
|
||||
.form-group { margin-bottom: 1.1rem; }
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.82rem; font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.73rem; color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.87rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus { border-color: var(--accent); }
|
||||
|
||||
.form-input::placeholder,
|
||||
.form-textarea::placeholder { color: var(--text-muted); }
|
||||
|
||||
.form-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238b8fa3'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.8rem center;
|
||||
padding-right: 2.2rem;
|
||||
}
|
||||
|
||||
.form-textarea { min-height: 80px; resize: vertical; }
|
||||
|
||||
/* Toggle */
|
||||
.toggle-wrapper {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 42px; height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle .slider {
|
||||
position: absolute; inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.toggle .slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px; top: 3px;
|
||||
width: 18px; height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
.toggle input:checked + .slider { background: var(--accent); }
|
||||
.toggle input:checked + .slider::before { transform: translateX(18px); }
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.82rem; font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
|
||||
.btn-success { background: var(--success); color: #000; }
|
||||
.btn-success:hover { filter: brightness(0.9); }
|
||||
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(0.85); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent; color: var(--text-secondary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-hover); }
|
||||
|
||||
.btn-sm { padding: 0.35rem 0.75rem; font-size: 0.78rem; }
|
||||
.btn-icon { padding: 0.5rem; width: 34px; height: 34px; justify-content: center; }
|
||||
|
||||
/* ── Badges / Tags ──────────────────────────────────────────────────── */
|
||||
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem; font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-success { background: var(--success-soft); color: var(--success); }
|
||||
.badge-danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.badge-warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.badge-info { background: var(--info-soft); color: var(--info); }
|
||||
.badge-primary { background: var(--accent-soft); color: var(--accent); }
|
||||
.badge-muted { background: rgba(255,255,255,0.06); color: var(--text-muted); }
|
||||
|
||||
/* ── Toasts ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.toast-container {
|
||||
position: fixed; top: 1.5rem; right: 1.5rem;
|
||||
z-index: 10000;
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex; align-items: center; gap: 0.65rem;
|
||||
padding: 0.75rem 1.1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 0.85rem; font-weight: 500;
|
||||
min-width: 280px; max-width: 400px;
|
||||
animation: slideInRight 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.toast.removing { opacity: 0; transform: translateX(100%); }
|
||||
.toast.success { border-left: 3px solid var(--success); }
|
||||
.toast.success i { color: var(--success); }
|
||||
.toast.error { border-left: 3px solid var(--danger); }
|
||||
.toast.error i { color: var(--danger); }
|
||||
.toast.warning { border-left: 3px solid var(--warning); }
|
||||
.toast.warning i { color: var(--warning); }
|
||||
.toast.info { border-left: 3px solid var(--info); }
|
||||
.toast.info i { color: var(--info); }
|
||||
|
||||
.toast .toast-close {
|
||||
margin-left: auto;
|
||||
background: none; border: none;
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { opacity: 0; transform: translateX(30px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ── Search ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
max-width: 300px;
|
||||
}
|
||||
.search-box i {
|
||||
position: absolute; left: 0.75rem; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted); font-size: 0.85rem;
|
||||
}
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem 0.55rem 2.2rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.search-box input:focus { border-color: var(--accent); }
|
||||
|
||||
/* ── Cog cards ──────────────────────────────────────────────────────── */
|
||||
|
||||
.cog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cog-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.2rem;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.cog-card:hover { border-color: var(--accent); }
|
||||
|
||||
.cog-card-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.cog-card-header h4 {
|
||||
font-size: 0.95rem; font-weight: 600;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cog-card p { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 0.75rem; line-height: 1.5; }
|
||||
|
||||
.cog-card-actions { display: flex; gap: 0.4rem; }
|
||||
|
||||
/* ── Command rows ───────────────────────────────────────────────────── */
|
||||
|
||||
.command-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.command-row:hover { background: var(--bg-tertiary); }
|
||||
|
||||
.command-info {
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.command-name {
|
||||
font-size: 0.87rem; font-weight: 600;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
.command-desc { font-size: 0.78rem; color: var(--text-secondary); margin-top: 0.1rem; }
|
||||
.command-aliases {
|
||||
display: flex; gap: 0.3rem; margin-top: 0.25rem;
|
||||
}
|
||||
.command-aliases span {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Accordion / collapsible cog sections ───────────────────────────── */
|
||||
|
||||
.accordion-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 0.6rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
user-select: none;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.accordion-header:hover { background: var(--bg-card-hover); }
|
||||
|
||||
.accordion-header h4 {
|
||||
font-size: 0.9rem; font-weight: 600;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
|
||||
.accordion-header .arrow {
|
||||
transition: transform var(--transition);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.accordion-item.open .accordion-header .arrow { transform: rotate(180deg); }
|
||||
|
||||
.accordion-body {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s ease;
|
||||
}
|
||||
.accordion-item.open .accordion-body { max-height: 2000px; }
|
||||
|
||||
.accordion-body-inner { padding: 0.5rem 0.75rem 1rem; }
|
||||
|
||||
/* ── Third-party settings auto-form ─────────────────────────────────── */
|
||||
|
||||
.settings-form { max-width: 600px; }
|
||||
|
||||
.settings-form .form-group {
|
||||
padding: 0.85rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.settings-form .form-group:last-child { border-bottom: none; }
|
||||
|
||||
/* ── Empty state ────────────────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
.empty-state i {
|
||||
font-size: 2.5rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.empty-state h3 {
|
||||
font-size: 1.1rem; font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 0.85rem; color: var(--text-secondary);
|
||||
max-width: 400px; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Chart container ────────────────────────────────────────────────── */
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Tab bar ────────────────────────────────────────────────────────── */
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.tab-item {
|
||||
padding: 0.65rem 1.1rem;
|
||||
font-size: 0.85rem; font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.tab-item:hover { color: var(--text-primary); }
|
||||
.tab-item.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Status indicator ───────────────────────────────────────────────── */
|
||||
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-dot.online { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.status-dot.offline { background: var(--danger); }
|
||||
|
||||
/* ── Guild header hero ──────────────────────────────────────────────── */
|
||||
|
||||
.guild-hero {
|
||||
display: flex; align-items: center; gap: 1.25rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
.guild-hero-icon {
|
||||
width: 72px; height: 72px;
|
||||
border-radius: var(--radius-lg);
|
||||
object-fit: cover;
|
||||
border: 3px solid var(--border);
|
||||
}
|
||||
.guild-hero-icon-placeholder {
|
||||
width: 72px; height: 72px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.8rem; font-weight: 700;
|
||||
border: 3px solid var(--border);
|
||||
}
|
||||
.guild-hero-info h2 { font-size: 1.4rem; font-weight: 700; }
|
||||
.guild-hero-info .guild-meta {
|
||||
display: flex; gap: 1.25rem; margin-top: 0.3rem;
|
||||
}
|
||||
.guild-hero-info .guild-meta span {
|
||||
font-size: 0.8rem; color: var(--text-secondary);
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
}
|
||||
.guild-hero-info .guild-meta i { font-size: 0.75rem; }
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { transform: translateX(-100%); width: var(--sidebar-width); }
|
||||
.sidebar.mobile-open { transform: translateX(0); }
|
||||
.main-content { margin-left: 0 !important; }
|
||||
.page-header, .page-body { padding: 1rem 1.25rem; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.login-features { grid-template-columns: 1fr; }
|
||||
.mobile-menu-btn { display: flex !important; }
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none !important;
|
||||
position: fixed; top: 1rem; left: 1rem;
|
||||
width: 40px; height: 40px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
align-items: center; justify-content: center;
|
||||
z-index: 99;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Misc utilities ─────────────────────────────────────────────────── */
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-1 { gap: 0.5rem; }
|
||||
.gap-2 { gap: 1rem; }
|
||||
.gap-3 { gap: 1.5rem; }
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.mt-3 { margin-top: 1.5rem; }
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
.text-sm { font-size: 0.82rem; }
|
||||
.text-xs { font-size: 0.73rem; }
|
||||
.font-mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
|
||||
.w-full { width: 100%; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
|
|
@ -3,6 +3,6 @@
|
|||
"install_msg": "Welcome to **EveCogs**! Use `[p]cog list` to see available cogs.",
|
||||
"name": "EveCogs",
|
||||
"short": "A collection of advanced cogs for Red-DiscordBot.",
|
||||
"description": "EveCogs is a premium cog repository featuring Wormhole v3.3.0 — the ultimate cross-server relay system with 70+ commands, DM relay, analytics, polls, bridging, auto-mod, and more.",
|
||||
"description": "EveCogs is a premium cog repository featuring Wormhole v3.3.0 (ultimate cross-server relay), NewHelpMenu (Components v2 help), and EveDash (self-contained web dashboard with cog SDK).",
|
||||
"type": "REPO"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue